@drakulavich/kesha-voice-kit 1.18.2 → 1.18.4
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 +11 -10
- package/completions/kesha.bash +2 -2
- package/completions/kesha.fish +7 -65
- package/completions/kesha.zsh +11 -3
- package/docs/product-positioning.md +4 -0
- package/man/kesha.1 +15 -2
- package/package.json +3 -4
- package/src/cli/dispatch.ts +17 -1
- package/src/cli/main.ts +51 -12
- package/src/cli/record.ts +71 -0
- package/src/cli.ts +3 -0
- package/src/engine.ts +74 -13
- package/src/log.ts +4 -1
- package/src/process-tree.ts +133 -0
- package/src/shell-artifacts.ts +16 -18
- package/src/synth.ts +15 -5
- package/src/transcribe.ts +4 -1
package/README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<p align="center">
|
|
2
|
-
<img src="assets/logo.png" alt="Kesha Voice Kit" width="200">
|
|
2
|
+
<img src="docs/assets/logo.png" alt="Kesha Voice Kit" width="200">
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
5
|
<h1 align="center">Kesha Voice Kit</h1>
|
|
@@ -11,12 +11,12 @@
|
|
|
11
11
|
<a href="https://bun.sh"><img src="https://img.shields.io/badge/runtime-Bun-f9f1e1?logo=bun" alt="Bun"></a>
|
|
12
12
|
</p>
|
|
13
13
|
|
|
14
|
-
<p align="center"><b>
|
|
14
|
+
<p align="center"><b>Give your local tools and LLM agents a voice.</b><br>Fast speech-to-text, text-to-speech, voice-activity detection, and language detection in one local-first CLI: Apple Silicon CoreML first, ONNX fallback on supported Linux/Windows builds.</p>
|
|
15
15
|
|
|
16
|
-
- **
|
|
17
|
-
- **
|
|
18
|
-
- **
|
|
19
|
-
- **
|
|
16
|
+
- **Transcribe locally** — 25 languages, up to ~19x faster than Whisper on Apple Silicon, ~2.5x on CPU
|
|
17
|
+
- **Speak back** — Kokoro (EN), Vosk-TTS (RU), macOS system voices, and SSML preview
|
|
18
|
+
- **Plug into agents** — ship voice workflows as CLI commands or an <a href="https://github.com/openclaw/openclaw">OpenClaw</a> skill
|
|
19
|
+
- **Small Rust engine** — single ~20MB binary, no ffmpeg, no Python, no native Node addons
|
|
20
20
|
|
|
21
21
|
See [Product positioning](docs/product-positioning.md) for supported workflows, non-goals, maturity labels, and the platform matrix.
|
|
22
22
|
|
|
@@ -209,9 +209,9 @@ $ kesha freedom.ogg tahiti.ogg
|
|
|
209
209
|
Таити, Таити! Не были мы ни в какой Таити! Нас и тут неплохо кормят.
|
|
210
210
|
```
|
|
211
211
|
|
|
212
|
-
Stdout: transcript. Stderr: errors. Pipe-friendly.
|
|
212
|
+
Stdout: transcript. Stderr: errors. Pipe-friendly.
|
|
213
213
|
|
|
214
|
-
For long / silence-heavy audio,
|
|
214
|
+
For long / silence-heavy audio, install VAD (`kesha install --vad`) and run without `--no-vad`. Kesha auto-uses VAD past 120 s when installed; without VAD, very long audio falls back to fixed ASR chunks. Details: [docs/vad.md](docs/vad.md).
|
|
215
215
|
|
|
216
216
|
**Speaker diarization** (darwin-arm64, post-v1.12.0):
|
|
217
217
|
|
|
@@ -267,11 +267,11 @@ macOS system voices, SSML, voice listing, and the full voice catalogue: [docs/tt
|
|
|
267
267
|
|
|
268
268
|
## Performance
|
|
269
269
|
|
|
270
|
-
>
|
|
270
|
+
> **Up to ~19x faster than Whisper** on Apple Silicon (M2), **~2.5x faster** on CPU
|
|
271
271
|
|
|
272
272
|
Compared against Whisper `large-v3-turbo` — all engines auto-detect language.
|
|
273
273
|
|
|
274
|
-

|
|
274
|
+

|
|
275
275
|
|
|
276
276
|
See [BENCHMARK.md](BENCHMARK.md) for the full per-file breakdown (Russian + English).
|
|
277
277
|
|
|
@@ -297,6 +297,7 @@ Audio decoding via [symphonia](https://github.com/pdeljanov/Symphonia) — WAV,
|
|
|
297
297
|
## Integrations
|
|
298
298
|
|
|
299
299
|
- **OpenClaw** — give your LLM agent ears. Install & config: [docs/openclaw.md](docs/openclaw.md).
|
|
300
|
+
- **Hermes Agent** — local STT/TTS through Hermes command providers. Setup: [docs/hermes.md](docs/hermes.md).
|
|
300
301
|
- **Raycast** (macOS) — transcribe selected audio & speak clipboard from the launcher. Source + install: [`raycast/`](raycast/).
|
|
301
302
|
|
|
302
303
|
## Programmatic API
|
package/completions/kesha.bash
CHANGED
|
@@ -6,7 +6,7 @@ _kesha_completion() {
|
|
|
6
6
|
COMPREPLY=()
|
|
7
7
|
cur="${COMP_WORDS[COMP_CWORD]}"
|
|
8
8
|
command="${COMP_WORDS[1]}"
|
|
9
|
-
commands="completions doctor install manpage say stats status support-bundle"
|
|
9
|
+
commands="completions doctor install manpage record say stats status support-bundle"
|
|
10
10
|
|
|
11
11
|
if [[ "$COMP_CWORD" -eq 1 ]]; then
|
|
12
12
|
if [[ "$cur" == -* ]]; then
|
|
@@ -22,6 +22,7 @@ _kesha_completion() {
|
|
|
22
22
|
doctor) opts="--help -h --json --redact" ;;
|
|
23
23
|
install) opts="--help -h --coreml --onnx --no-cache --plan --tts --vad --diarize" ;;
|
|
24
24
|
manpage) opts="--help -h" ;;
|
|
25
|
+
record) opts="--help -h --out --max-seconds --debug" ;;
|
|
25
26
|
say) opts="--help -h --voice --lang --out --rate --list-voices --ssml --format --bitrate --sample-rate --no-expand-abbrev --verbose --debug" ;;
|
|
26
27
|
stats) opts="--help -h --format" ;;
|
|
27
28
|
status) opts="--help -h --disk" ;;
|
|
@@ -35,4 +36,3 @@ _kesha_completion() {
|
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
complete -F _kesha_completion kesha
|
|
38
|
-
complete -F _kesha_completion parakeet
|
package/completions/kesha.fish
CHANGED
|
@@ -4,6 +4,7 @@ complete -c kesha -n '__fish_use_subcommand' -a 'completions' -d 'Print shell co
|
|
|
4
4
|
complete -c kesha -n '__fish_use_subcommand' -a 'doctor' -d 'Collect support diagnostics without changing local state'
|
|
5
5
|
complete -c kesha -n '__fish_use_subcommand' -a 'install' -d 'Download inference engine and models'
|
|
6
6
|
complete -c kesha -n '__fish_use_subcommand' -a 'manpage' -d 'Print the kesha(1) manpage'
|
|
7
|
+
complete -c kesha -n '__fish_use_subcommand' -a 'record' -d 'Record microphone audio to a WAV file'
|
|
7
8
|
complete -c kesha -n '__fish_use_subcommand' -a 'say' -d 'Synthesize speech from text (TTS). Writes audio to stdout (or --out file). Defaults to WAV; use --format ogg-opus for messenger-ready voice notes.'
|
|
8
9
|
complete -c kesha -n '__fish_use_subcommand' -a 'stats' -d 'Manage local anonymous Kesha Stats'
|
|
9
10
|
complete -c kesha -n '__fish_use_subcommand' -a 'status' -d 'Show backend installation status'
|
|
@@ -22,7 +23,7 @@ complete -c kesha -n '__fish_use_subcommand' -l format -r -d 'Output format: tra
|
|
|
22
23
|
complete -c kesha -n '__fish_use_subcommand' -l lang -r -d 'Expected language code (ISO 639-1), warn if mismatch'
|
|
23
24
|
complete -c kesha -n '__fish_use_subcommand' -l debug -d 'Trace engine subprocess calls on stderr (or KESHA_DEBUG=1)'
|
|
24
25
|
complete -c kesha -n '__fish_use_subcommand' -l vad -d 'Force Silero VAD preprocessing (kesha install --vad first). Without this, VAD auto-engages on audio ≥ 120s.'
|
|
25
|
-
complete -c kesha -n '__fish_use_subcommand' -l no-vad -d '
|
|
26
|
+
complete -c kesha -n '__fish_use_subcommand' -l no-vad -d 'Force full-file ASR for short/medium files; long audio fails early'
|
|
26
27
|
complete -c kesha -n '__fish_seen_subcommand_from completions' -l help -d 'Show help'
|
|
27
28
|
complete -c kesha -n '__fish_seen_subcommand_from completions' -s h -d 'Show help'
|
|
28
29
|
complete -c kesha -n '__fish_seen_subcommand_from doctor' -l help -d 'Show help'
|
|
@@ -40,6 +41,11 @@ complete -c kesha -n '__fish_seen_subcommand_from install' -l vad -d 'Also insta
|
|
|
40
41
|
complete -c kesha -n '__fish_seen_subcommand_from install' -l diarize -d 'Also install the Sortformer streaming-diarization model (~245MB, darwin-arm64 only — #199)'
|
|
41
42
|
complete -c kesha -n '__fish_seen_subcommand_from manpage' -l help -d 'Show help'
|
|
42
43
|
complete -c kesha -n '__fish_seen_subcommand_from manpage' -s h -d 'Show help'
|
|
44
|
+
complete -c kesha -n '__fish_seen_subcommand_from record' -l help -d 'Show help'
|
|
45
|
+
complete -c kesha -n '__fish_seen_subcommand_from record' -s h -d 'Show help'
|
|
46
|
+
complete -c kesha -n '__fish_seen_subcommand_from record' -l out -r -d 'Write recorded WAV audio to this path'
|
|
47
|
+
complete -c kesha -n '__fish_seen_subcommand_from record' -l max-seconds -r -d 'Maximum recording duration in seconds'
|
|
48
|
+
complete -c kesha -n '__fish_seen_subcommand_from record' -l debug -d 'Trace engine subprocess calls on stderr (or KESHA_DEBUG=1)'
|
|
43
49
|
complete -c kesha -n '__fish_seen_subcommand_from say' -l help -d 'Show help'
|
|
44
50
|
complete -c kesha -n '__fish_seen_subcommand_from say' -s h -d 'Show help'
|
|
45
51
|
complete -c kesha -n '__fish_seen_subcommand_from say' -l voice -r -d 'Voice id, e.g. en-am_michael'
|
|
@@ -63,67 +69,3 @@ complete -c kesha -n '__fish_seen_subcommand_from status' -l disk -d 'Include re
|
|
|
63
69
|
complete -c kesha -n '__fish_seen_subcommand_from support-bundle' -l help -d 'Show help'
|
|
64
70
|
complete -c kesha -n '__fish_seen_subcommand_from support-bundle' -s h -d 'Show help'
|
|
65
71
|
complete -c kesha -n '__fish_seen_subcommand_from support-bundle' -l output -r -d 'Write archive to this .tar.gz path'
|
|
66
|
-
complete -c parakeet -f
|
|
67
|
-
complete -c parakeet -n '__fish_use_subcommand' -a 'completions' -d 'Print shell completion script for bash, zsh, or fish'
|
|
68
|
-
complete -c parakeet -n '__fish_use_subcommand' -a 'doctor' -d 'Collect support diagnostics without changing local state'
|
|
69
|
-
complete -c parakeet -n '__fish_use_subcommand' -a 'install' -d 'Download inference engine and models'
|
|
70
|
-
complete -c parakeet -n '__fish_use_subcommand' -a 'manpage' -d 'Print the kesha(1) manpage'
|
|
71
|
-
complete -c parakeet -n '__fish_use_subcommand' -a 'say' -d 'Synthesize speech from text (TTS). Writes audio to stdout (or --out file). Defaults to WAV; use --format ogg-opus for messenger-ready voice notes.'
|
|
72
|
-
complete -c parakeet -n '__fish_use_subcommand' -a 'stats' -d 'Manage local anonymous Kesha Stats'
|
|
73
|
-
complete -c parakeet -n '__fish_use_subcommand' -a 'status' -d 'Show backend installation status'
|
|
74
|
-
complete -c parakeet -n '__fish_use_subcommand' -a 'support-bundle' -d 'Create a redacted diagnostics archive for support'
|
|
75
|
-
complete -c parakeet -n '__fish_use_subcommand' -l help -d 'Show help'
|
|
76
|
-
complete -c parakeet -n '__fish_use_subcommand' -s h -d 'Show help'
|
|
77
|
-
complete -c parakeet -n '__fish_use_subcommand' -l version -d 'Show version'
|
|
78
|
-
complete -c parakeet -n '__fish_use_subcommand' -s v -d 'Show version'
|
|
79
|
-
complete -c parakeet -n '__fish_use_subcommand' -l json -d 'Output results as JSON'
|
|
80
|
-
complete -c parakeet -n '__fish_use_subcommand' -l toon -d 'Output results as TOON (compact, LLM-friendly encoding of the same data as --json)'
|
|
81
|
-
complete -c parakeet -n '__fish_use_subcommand' -l timestamps -d 'Include timestamped transcript segments in JSON/TOON output'
|
|
82
|
-
complete -c parakeet -n '__fish_use_subcommand' -l speakers -d 'Include speaker labels in transcript segments. Requires --json / --toon / --format json. Implies --timestamps. Currently darwin-arm64 only (#199).'
|
|
83
|
-
complete -c parakeet -n '__fish_use_subcommand' -l include-errors -d 'With --json, output { results, errors } so scripts can read per-file failures without parsing stderr'
|
|
84
|
-
complete -c parakeet -n '__fish_use_subcommand' -l verbose -d 'Show language detection details'
|
|
85
|
-
complete -c parakeet -n '__fish_use_subcommand' -l format -r -d 'Output format: transcript | json | toon (long-form alias for --json / --toon)'
|
|
86
|
-
complete -c parakeet -n '__fish_use_subcommand' -l lang -r -d 'Expected language code (ISO 639-1), warn if mismatch'
|
|
87
|
-
complete -c parakeet -n '__fish_use_subcommand' -l debug -d 'Trace engine subprocess calls on stderr (or KESHA_DEBUG=1)'
|
|
88
|
-
complete -c parakeet -n '__fish_use_subcommand' -l vad -d 'Force Silero VAD preprocessing (kesha install --vad first). Without this, VAD auto-engages on audio ≥ 120s.'
|
|
89
|
-
complete -c parakeet -n '__fish_use_subcommand' -l no-vad -d 'Disable VAD preprocessing regardless of duration or install state'
|
|
90
|
-
complete -c parakeet -n '__fish_seen_subcommand_from completions' -l help -d 'Show help'
|
|
91
|
-
complete -c parakeet -n '__fish_seen_subcommand_from completions' -s h -d 'Show help'
|
|
92
|
-
complete -c parakeet -n '__fish_seen_subcommand_from doctor' -l help -d 'Show help'
|
|
93
|
-
complete -c parakeet -n '__fish_seen_subcommand_from doctor' -s h -d 'Show help'
|
|
94
|
-
complete -c parakeet -n '__fish_seen_subcommand_from doctor' -l json -d 'Output diagnostics as JSON'
|
|
95
|
-
complete -c parakeet -n '__fish_seen_subcommand_from doctor' -l redact -d 'Redact secrets and user-home paths from diagnostic output'
|
|
96
|
-
complete -c parakeet -n '__fish_seen_subcommand_from install' -l help -d 'Show help'
|
|
97
|
-
complete -c parakeet -n '__fish_seen_subcommand_from install' -s h -d 'Show help'
|
|
98
|
-
complete -c parakeet -n '__fish_seen_subcommand_from install' -l coreml -d 'Force CoreML backend (macOS arm64)'
|
|
99
|
-
complete -c parakeet -n '__fish_seen_subcommand_from install' -l onnx -d 'Force ONNX backend'
|
|
100
|
-
complete -c parakeet -n '__fish_seen_subcommand_from install' -l no-cache -d 'Re-download even if cached'
|
|
101
|
-
complete -c parakeet -n '__fish_seen_subcommand_from install' -l plan -d 'Show download, disk, and warm-up plan without changing local state'
|
|
102
|
-
complete -c parakeet -n '__fish_seen_subcommand_from install' -l tts -d 'Also install TTS models (Kokoro EN + Vosk-TTS RU, ~990MB)'
|
|
103
|
-
complete -c parakeet -n '__fish_seen_subcommand_from install' -l vad -d 'Also install Silero VAD (~2.3MB) for long-audio preprocessing'
|
|
104
|
-
complete -c parakeet -n '__fish_seen_subcommand_from install' -l diarize -d 'Also install the Sortformer streaming-diarization model (~245MB, darwin-arm64 only — #199)'
|
|
105
|
-
complete -c parakeet -n '__fish_seen_subcommand_from manpage' -l help -d 'Show help'
|
|
106
|
-
complete -c parakeet -n '__fish_seen_subcommand_from manpage' -s h -d 'Show help'
|
|
107
|
-
complete -c parakeet -n '__fish_seen_subcommand_from say' -l help -d 'Show help'
|
|
108
|
-
complete -c parakeet -n '__fish_seen_subcommand_from say' -s h -d 'Show help'
|
|
109
|
-
complete -c parakeet -n '__fish_seen_subcommand_from say' -l voice -r -d 'Voice id, e.g. en-am_michael'
|
|
110
|
-
complete -c parakeet -n '__fish_seen_subcommand_from say' -l lang -r -d 'BCP 47 language code (default en-us)'
|
|
111
|
-
complete -c parakeet -n '__fish_seen_subcommand_from say' -l out -r -d 'Write audio to file instead of stdout'
|
|
112
|
-
complete -c parakeet -n '__fish_seen_subcommand_from say' -l rate -r -d 'Speaking rate 0.5–2.0'
|
|
113
|
-
complete -c parakeet -n '__fish_seen_subcommand_from say' -l list-voices -d 'List installed voices and exit'
|
|
114
|
-
complete -c parakeet -n '__fish_seen_subcommand_from say' -l ssml -d 'Parse input as SSML (supports <speak>, <break>; strips unknown tags)'
|
|
115
|
-
complete -c parakeet -n '__fish_seen_subcommand_from say' -l format -r -d 'Output format: wav (default) or ogg-opus (Telegram-ready voice note). Inferred from --out extension when omitted.'
|
|
116
|
-
complete -c parakeet -n '__fish_seen_subcommand_from say' -l bitrate -r -d 'Opus bitrate in bits/sec (e.g. 32000). Only with --format ogg-opus.'
|
|
117
|
-
complete -c parakeet -n '__fish_seen_subcommand_from say' -l sample-rate -r -d 'Opus encoder sample rate (8000/12000/16000/24000/48000). Only with --format ogg-opus.'
|
|
118
|
-
complete -c parakeet -n '__fish_seen_subcommand_from say' -l no-expand-abbrev -d 'Disable Russian acronym auto-expansion (ВОЗ → \'вэ о зэ\') for ru-vosk-* voices. <say-as interpret-as=\'characters\'> still works. Applies to Russian (ru-vosk-*) and English (en-*) voices.'
|
|
119
|
-
complete -c parakeet -n '__fish_seen_subcommand_from say' -l verbose -d 'Log TTS synthesis time to stderr'
|
|
120
|
-
complete -c parakeet -n '__fish_seen_subcommand_from say' -l debug -d 'Trace engine subprocess calls on stderr (or KESHA_DEBUG=1)'
|
|
121
|
-
complete -c parakeet -n '__fish_seen_subcommand_from stats' -l help -d 'Show help'
|
|
122
|
-
complete -c parakeet -n '__fish_seen_subcommand_from stats' -s h -d 'Show help'
|
|
123
|
-
complete -c parakeet -n '__fish_seen_subcommand_from stats' -l format -r -d 'Export format: json | csv'
|
|
124
|
-
complete -c parakeet -n '__fish_seen_subcommand_from status' -l help -d 'Show help'
|
|
125
|
-
complete -c parakeet -n '__fish_seen_subcommand_from status' -s h -d 'Show help'
|
|
126
|
-
complete -c parakeet -n '__fish_seen_subcommand_from status' -l disk -d 'Include recursive cache disk usage'
|
|
127
|
-
complete -c parakeet -n '__fish_seen_subcommand_from support-bundle' -l help -d 'Show help'
|
|
128
|
-
complete -c parakeet -n '__fish_seen_subcommand_from support-bundle' -s h -d 'Show help'
|
|
129
|
-
complete -c parakeet -n '__fish_seen_subcommand_from support-bundle' -l output -r -d 'Write archive to this .tar.gz path'
|
package/completions/kesha.zsh
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#compdef kesha
|
|
1
|
+
#compdef kesha
|
|
2
2
|
# zsh completion for kesha.
|
|
3
3
|
|
|
4
4
|
_kesha() {
|
|
@@ -8,6 +8,7 @@ _kesha() {
|
|
|
8
8
|
'doctor:Collect support diagnostics without changing local state'
|
|
9
9
|
'install:Download inference engine and models'
|
|
10
10
|
'manpage:Print the kesha(1) manpage'
|
|
11
|
+
'record:Record microphone audio to a WAV file'
|
|
11
12
|
'say:Synthesize speech from text (TTS). Writes audio to stdout (or --out file). Defaults to WAV; use --format ogg-opus for messenger-ready voice notes.'
|
|
12
13
|
'stats:Manage local anonymous Kesha Stats'
|
|
13
14
|
'status:Show backend installation status'
|
|
@@ -30,7 +31,7 @@ _kesha() {
|
|
|
30
31
|
'--lang=[Expected language code (ISO 639-1), warn if mismatch]:lang:' \
|
|
31
32
|
'--debug[Trace engine subprocess calls on stderr (or KESHA_DEBUG=1)]' \
|
|
32
33
|
'--vad[Force Silero VAD preprocessing (kesha install --vad first). Without this, VAD auto-engages on audio ≥ 120s.]' \
|
|
33
|
-
'--no-vad[
|
|
34
|
+
'--no-vad[Force full-file ASR for short/medium files; long audio fails early]'
|
|
34
35
|
else
|
|
35
36
|
_describe -t commands 'kesha command' commands
|
|
36
37
|
fi
|
|
@@ -63,6 +64,13 @@ _kesha() {
|
|
|
63
64
|
_arguments '--help[Show help]' \
|
|
64
65
|
'-h[Show help]'
|
|
65
66
|
;;
|
|
67
|
+
record)
|
|
68
|
+
_arguments '--help[Show help]' \
|
|
69
|
+
'-h[Show help]' \
|
|
70
|
+
'--out=[Write recorded WAV audio to this path]:out:' \
|
|
71
|
+
'--max-seconds=[Maximum recording duration in seconds]:max seconds:' \
|
|
72
|
+
'--debug[Trace engine subprocess calls on stderr (or KESHA_DEBUG=1)]'
|
|
73
|
+
;;
|
|
66
74
|
say)
|
|
67
75
|
_arguments '--help[Show help]' \
|
|
68
76
|
'-h[Show help]' \
|
|
@@ -109,7 +117,7 @@ _kesha() {
|
|
|
109
117
|
'--lang=[Expected language code (ISO 639-1), warn if mismatch]:lang:' \
|
|
110
118
|
'--debug[Trace engine subprocess calls on stderr (or KESHA_DEBUG=1)]' \
|
|
111
119
|
'--vad[Force Silero VAD preprocessing (kesha install --vad first). Without this, VAD auto-engages on audio ≥ 120s.]' \
|
|
112
|
-
'--no-vad[
|
|
120
|
+
'--no-vad[Force full-file ASR for short/medium files; long audio fails early]'
|
|
113
121
|
;;
|
|
114
122
|
esac
|
|
115
123
|
}
|
|
@@ -8,6 +8,7 @@ The product promise is not "one model for every audio problem." The promise is a
|
|
|
8
8
|
|
|
9
9
|
- Developers and agent builders who need local speech-to-text for voice messages, meetings, support clips, or batch audio files.
|
|
10
10
|
- OpenClaw users who want private, low-latency voice-message transcription through a CLI model route.
|
|
11
|
+
- Hermes Agent users who want command-provider STT/TTS without cloud audio uploads.
|
|
11
12
|
- macOS users who want the fastest path on Apple Silicon through CoreML, with a CPU/ONNX fallback on Linux and Windows.
|
|
12
13
|
- Automation-heavy users who prefer stdout/stderr contracts, JSON/TOON output, and shell-friendly commands over a hosted dashboard.
|
|
13
14
|
- Projects that can accept explicit model downloads and local cache management in exchange for no API keys and no cloud audio upload.
|
|
@@ -24,6 +25,7 @@ The product promise is not "one model for every audio problem." The promise is a
|
|
|
24
25
|
| Use macOS system voices without model downloads | Stable on macOS | `kesha say --voice macos-*` |
|
|
25
26
|
| Label speakers in meeting transcripts | Preview, darwin-arm64 only | `kesha --json --vad --speakers meeting.m4a` |
|
|
26
27
|
| Integrate with OpenClaw as a local voice model | Stable integration surface, user-configured route | `docs/openclaw.md` |
|
|
28
|
+
| Integrate with Hermes Agent as local STT/TTS commands | Stable CLI surface, user-configured route | `docs/hermes.md` |
|
|
27
29
|
| Use Raycast actions for selected-file transcription or clipboard speech | Beta, macOS only | `raycast/` extension |
|
|
28
30
|
| Install through Nix instead of Bun/npm | Beta, selected systems | `docs/nix-install.md` |
|
|
29
31
|
|
|
@@ -51,6 +53,7 @@ The product promise is not "one model for every audio problem." The promise is a
|
|
|
51
53
|
| Speaker diarization | Preview | darwin-arm64 only; cluster IDs are stable within one file only. |
|
|
52
54
|
| Raycast extension | Beta | Convenience UI over the CLI; macOS-only. |
|
|
53
55
|
| OpenClaw plugin | Stable integration surface | Discovery is packaged; actual audio route remains explicit user config. |
|
|
56
|
+
| Hermes Agent command provider | Stable CLI surface | Hermes owns the command-provider lifecycle; Kesha supplies the local STT/TTS commands. |
|
|
54
57
|
| Nix flake | Beta | Reproducible alternate install path for selected systems; Bun/npm remains canonical. |
|
|
55
58
|
|
|
56
59
|
## Platform matrix
|
|
@@ -66,6 +69,7 @@ The product promise is not "one model for every audio problem." The promise is a
|
|
|
66
69
|
| Speaker diarization | Preview, darwin-arm64 only | Not supported | Not supported | Not supported | Not wired into the Nix build yet |
|
|
67
70
|
| Raycast extension | Supported | Not shipped | Not applicable | Not applicable | Not applicable |
|
|
68
71
|
| OpenClaw integration | Supported through CLI route | Not shipped | Supported through CLI route | Supported through CLI route | Use the installed `kesha` command from the chosen path |
|
|
72
|
+
| Hermes Agent integration | Supported through command providers | Not shipped | Supported through command providers | Supported through command providers | Use the installed `kesha` command from the chosen path |
|
|
69
73
|
|
|
70
74
|
`macOS x64` means Intel Macs. Kesha does not currently publish a `darwin-x64` engine binary, so Intel Macs are intentionally marked as not shipped rather than implied to use the ONNX fallback.
|
|
71
75
|
|
package/man/kesha.1
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
.TH KESHA 1 "May 2026" "kesha 1.18.
|
|
1
|
+
.TH KESHA 1 "May 2026" "kesha 1.18.4" "User Commands"
|
|
2
2
|
.SH NAME
|
|
3
3
|
kesha \- open-source local voice toolkit
|
|
4
4
|
.SH SYNOPSIS
|
|
@@ -23,6 +23,9 @@ Download inference engine and models
|
|
|
23
23
|
.B manpage
|
|
24
24
|
Print the kesha(1) manpage
|
|
25
25
|
.TP
|
|
26
|
+
.B record
|
|
27
|
+
Record microphone audio to a WAV file
|
|
28
|
+
.TP
|
|
26
29
|
.B say
|
|
27
30
|
Synthesize speech from text (TTS). Writes audio to stdout (or \-\-out file). Defaults to WAV; use \-\-format ogg\-opus for messenger\-ready voice notes.
|
|
28
31
|
.TP
|
|
@@ -67,7 +70,7 @@ Trace engine subprocess calls on stderr (or KESHA_DEBUG=1)
|
|
|
67
70
|
Force Silero VAD preprocessing (kesha install \-\-vad first). Without this, VAD auto\-engages on audio ≥ 120s.
|
|
68
71
|
.TP
|
|
69
72
|
.B \-\-no\-vad
|
|
70
|
-
|
|
73
|
+
Force full\-file ASR for short/medium files; long audio fails early
|
|
71
74
|
.SH SUBCOMMAND OPTIONS
|
|
72
75
|
.SS doctor
|
|
73
76
|
.TP
|
|
@@ -98,6 +101,16 @@ Also install Silero VAD (~2.3MB) for long\-audio preprocessing
|
|
|
98
101
|
.TP
|
|
99
102
|
.B \-\-diarize
|
|
100
103
|
Also install the Sortformer streaming\-diarization model (~245MB, darwin\-arm64 only — #199)
|
|
104
|
+
.SS record
|
|
105
|
+
.TP
|
|
106
|
+
.B \-\-out
|
|
107
|
+
Write recorded WAV audio to this path
|
|
108
|
+
.TP
|
|
109
|
+
.B \-\-max\-seconds
|
|
110
|
+
Maximum recording duration in seconds
|
|
111
|
+
.TP
|
|
112
|
+
.B \-\-debug
|
|
113
|
+
Trace engine subprocess calls on stderr (or KESHA_DEBUG=1)
|
|
101
114
|
.SS say
|
|
102
115
|
.TP
|
|
103
116
|
.B \-\-voice
|
package/package.json
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drakulavich/kesha-voice-kit",
|
|
3
|
-
"version": "1.18.
|
|
3
|
+
"version": "1.18.4",
|
|
4
4
|
"description": "Give your local tools a voice: fast STT in 25 languages (~19x faster than Whisper on Apple Silicon, ONNX CPU fallback), TTS via Kokoro/Vosk/macOS voices, VAD + 107-language detection. Rust engine, Bun CLI, OpenClaw skill.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"kesha": "bin/kesha.js"
|
|
8
|
-
"parakeet": "bin/kesha.js"
|
|
7
|
+
"kesha": "bin/kesha.js"
|
|
9
8
|
},
|
|
10
9
|
"exports": {
|
|
11
10
|
".": "./src/cli.ts",
|
|
@@ -33,7 +32,7 @@
|
|
|
33
32
|
]
|
|
34
33
|
},
|
|
35
34
|
"keshaEngine": {
|
|
36
|
-
"version": "1.18.
|
|
35
|
+
"version": "1.18.4"
|
|
37
36
|
},
|
|
38
37
|
"scripts": {
|
|
39
38
|
"test": "bun test",
|
package/src/cli/dispatch.ts
CHANGED
|
@@ -6,13 +6,24 @@ import { completionsCommand } from "./completions";
|
|
|
6
6
|
import { doctorCommand } from "./doctor";
|
|
7
7
|
import { installCommand } from "./install";
|
|
8
8
|
import { manpageCommand } from "./manpage";
|
|
9
|
+
import { recordCommand } from "./record";
|
|
9
10
|
import { sayCommand } from "./say";
|
|
10
11
|
import { statsCommand } from "./stats";
|
|
11
12
|
import { statusCommand } from "./status";
|
|
12
13
|
import { supportBundleCommand } from "./support-bundle";
|
|
13
14
|
import { mainCommand } from "./main";
|
|
14
15
|
|
|
15
|
-
const SUBCOMMANDS = [
|
|
16
|
+
const SUBCOMMANDS = [
|
|
17
|
+
"doctor",
|
|
18
|
+
"install",
|
|
19
|
+
"status",
|
|
20
|
+
"record",
|
|
21
|
+
"say",
|
|
22
|
+
"stats",
|
|
23
|
+
"support-bundle",
|
|
24
|
+
"completions",
|
|
25
|
+
"manpage",
|
|
26
|
+
];
|
|
16
27
|
|
|
17
28
|
function isPathLike(arg: string): boolean {
|
|
18
29
|
return arg.includes(".") || arg.includes("/") || existsSync(arg);
|
|
@@ -46,6 +57,11 @@ export async function runCli(rawArgs = process.argv.slice(2)): Promise<void> {
|
|
|
46
57
|
return;
|
|
47
58
|
}
|
|
48
59
|
|
|
60
|
+
if (firstArg === "record") {
|
|
61
|
+
await runMain(recordCommand, { rawArgs: restArgs });
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
49
65
|
if (firstArg === "say") {
|
|
50
66
|
await runMain(sayCommand, { rawArgs: restArgs });
|
|
51
67
|
return;
|
package/src/cli/main.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { packageVersion } from "../package-info";
|
|
|
16
16
|
import { formatToonOutput } from "../toon";
|
|
17
17
|
import { artifactFromFile, createStatsRecorder } from "../stats";
|
|
18
18
|
import { createPercentProgress } from "../progress";
|
|
19
|
+
import { getPendingSignalExitCode, waitForPendingSignalCleanup } from "../process-tree";
|
|
19
20
|
|
|
20
21
|
interface MainCommandArgs {
|
|
21
22
|
_: string[];
|
|
@@ -107,6 +108,15 @@ export function checkLanguageMismatch(expected: string | undefined, detected: st
|
|
|
107
108
|
return `warning: expected language "${expected}" but detected "${detected}"`;
|
|
108
109
|
}
|
|
109
110
|
|
|
111
|
+
export function shouldReportTranscribeProgress(input: {
|
|
112
|
+
stderrIsTty: boolean;
|
|
113
|
+
stdoutIsTty: boolean;
|
|
114
|
+
debugEnabled: boolean;
|
|
115
|
+
}): boolean {
|
|
116
|
+
if (input.debugEnabled) return false;
|
|
117
|
+
return input.stderrIsTty || !input.stdoutIsTty;
|
|
118
|
+
}
|
|
119
|
+
|
|
110
120
|
export const mainCommand = defineCommand({
|
|
111
121
|
meta: {
|
|
112
122
|
name: "kesha",
|
|
@@ -119,6 +129,7 @@ export const mainCommand = defineCommand({
|
|
|
119
129
|
" doctor Collect support diagnostics.\n" +
|
|
120
130
|
" install Download engine and models.\n" +
|
|
121
131
|
" manpage Print the kesha(1) manpage.\n" +
|
|
132
|
+
" record Record microphone audio to a WAV file.\n" +
|
|
122
133
|
" status Inspect installed backend.\n" +
|
|
123
134
|
" say Synthesize speech from text.\n" +
|
|
124
135
|
" stats Manage local anonymous performance stats.\n" +
|
|
@@ -175,7 +186,7 @@ export const mainCommand = defineCommand({
|
|
|
175
186
|
},
|
|
176
187
|
"no-vad": {
|
|
177
188
|
type: "boolean",
|
|
178
|
-
description: "
|
|
189
|
+
description: "Force full-file ASR for short/medium files; long audio fails early",
|
|
179
190
|
default: false,
|
|
180
191
|
},
|
|
181
192
|
},
|
|
@@ -229,6 +240,7 @@ export const mainCommand = defineCommand({
|
|
|
229
240
|
" kesha doctor [--json] [--redact]\n" +
|
|
230
241
|
" kesha install [--no-cache]\n" +
|
|
231
242
|
" kesha manpage\n" +
|
|
243
|
+
" kesha record --out path.wav [--max-seconds 120]\n" +
|
|
232
244
|
" kesha status\n" +
|
|
233
245
|
" kesha say <text>\n" +
|
|
234
246
|
" kesha stats [enable|disable|status|week|errors|export|reset|vacuum|retention]\n" +
|
|
@@ -243,7 +255,11 @@ export const mainCommand = defineCommand({
|
|
|
243
255
|
const stats = createStatsRecorder("transcribe");
|
|
244
256
|
|
|
245
257
|
const wantsLangId = !!(args.lang || args.verbose || wantsJson || wantsToon || wantsTranscript);
|
|
246
|
-
const reportProgress =
|
|
258
|
+
const reportProgress = shouldReportTranscribeProgress({
|
|
259
|
+
stderrIsTty: process.stderr.isTTY === true,
|
|
260
|
+
stdoutIsTty: process.stdout.isTTY === true,
|
|
261
|
+
debugEnabled: log.isDebugEnabled(),
|
|
262
|
+
});
|
|
247
263
|
|
|
248
264
|
for (const file of files) {
|
|
249
265
|
if (!existsSync(file)) {
|
|
@@ -269,15 +285,31 @@ export const mainCommand = defineCommand({
|
|
|
269
285
|
estimatedTotalMs: args.speakers ? 60 * 60 * 1000 : 30 * 60 * 1000,
|
|
270
286
|
})
|
|
271
287
|
: null;
|
|
272
|
-
// Run audio lang-id and transcription concurrently
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
)
|
|
280
|
-
|
|
288
|
+
// Run audio lang-id and transcription concurrently, but do not leave
|
|
289
|
+
// the sibling engine process alive if one side fails first.
|
|
290
|
+
const engineAbort = new AbortController();
|
|
291
|
+
const audioPromise = wantsLangId
|
|
292
|
+
? stats.timeStage("lang_id_audio", () =>
|
|
293
|
+
detectAudioLanguageEngine(file, { signal: engineAbort.signal })
|
|
294
|
+
)
|
|
295
|
+
: Promise.resolve(null);
|
|
296
|
+
const transcriptPromise = stats.timeStage("transcribe", () =>
|
|
297
|
+
transcribeWithSegments(file, {
|
|
298
|
+
vad: vadMode,
|
|
299
|
+
signal: engineAbort.signal,
|
|
300
|
+
timestamps: args.timestamps,
|
|
301
|
+
speakers: args.speakers,
|
|
302
|
+
})
|
|
303
|
+
);
|
|
304
|
+
let audioResult: LangDetectResult | null;
|
|
305
|
+
let transcript: Awaited<ReturnType<typeof transcribeWithSegments>>;
|
|
306
|
+
try {
|
|
307
|
+
[audioResult, transcript] = await Promise.all([audioPromise, transcriptPromise]);
|
|
308
|
+
} catch (err) {
|
|
309
|
+
engineAbort.abort();
|
|
310
|
+
await Promise.allSettled([audioPromise, transcriptPromise]);
|
|
311
|
+
throw err;
|
|
312
|
+
}
|
|
281
313
|
const { text, segments } = transcript;
|
|
282
314
|
|
|
283
315
|
let audioLanguage: LangDetectResult | undefined;
|
|
@@ -347,6 +379,13 @@ export const mainCommand = defineCommand({
|
|
|
347
379
|
|
|
348
380
|
stats.finish(hasError ? "failed" : "success", files.length);
|
|
349
381
|
|
|
350
|
-
if (hasError)
|
|
382
|
+
if (hasError) {
|
|
383
|
+
const signalExitCode = getPendingSignalExitCode();
|
|
384
|
+
if (signalExitCode !== null) {
|
|
385
|
+
await waitForPendingSignalCleanup();
|
|
386
|
+
process.exit(signalExitCode);
|
|
387
|
+
}
|
|
388
|
+
process.exit(1);
|
|
389
|
+
}
|
|
351
390
|
},
|
|
352
391
|
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { defineCommand } from "citty";
|
|
2
|
+
import { recordEngine } from "../engine";
|
|
3
|
+
import { log } from "../log";
|
|
4
|
+
|
|
5
|
+
export interface RecordArgs {
|
|
6
|
+
out?: string;
|
|
7
|
+
"max-seconds"?: string | number;
|
|
8
|
+
debug?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type ResolvedRecordArgs =
|
|
12
|
+
| { ok: true; out: string; maxSeconds: number }
|
|
13
|
+
| { ok: false; error: string };
|
|
14
|
+
|
|
15
|
+
const DEFAULT_MAX_SECONDS = 120;
|
|
16
|
+
const MAX_RECORD_SECONDS = 3600;
|
|
17
|
+
|
|
18
|
+
export function resolveRecordArgs(args: RecordArgs): ResolvedRecordArgs {
|
|
19
|
+
const out = typeof args.out === "string" ? args.out.trim() : "";
|
|
20
|
+
if (!out) {
|
|
21
|
+
return { ok: false, error: "kesha record requires --out <path>." };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const rawMax = args["max-seconds"] ?? String(DEFAULT_MAX_SECONDS);
|
|
25
|
+
const raw = String(rawMax).trim();
|
|
26
|
+
const maxSeconds = Number(raw);
|
|
27
|
+
if (raw === "" || !Number.isFinite(maxSeconds)) {
|
|
28
|
+
return { ok: false, error: "--max-seconds must be a finite number." };
|
|
29
|
+
}
|
|
30
|
+
if (!Number.isInteger(maxSeconds) || maxSeconds <= 0 || maxSeconds > MAX_RECORD_SECONDS) {
|
|
31
|
+
return {
|
|
32
|
+
ok: false,
|
|
33
|
+
error: `--max-seconds must be an integer between 1 and ${MAX_RECORD_SECONDS}.`,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return { ok: true, out, maxSeconds };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const recordCommand = defineCommand({
|
|
41
|
+
meta: {
|
|
42
|
+
name: "record",
|
|
43
|
+
description: "Record microphone audio to a WAV file",
|
|
44
|
+
},
|
|
45
|
+
args: {
|
|
46
|
+
out: {
|
|
47
|
+
type: "string",
|
|
48
|
+
description: "Write recorded WAV audio to this path",
|
|
49
|
+
required: true,
|
|
50
|
+
},
|
|
51
|
+
"max-seconds": {
|
|
52
|
+
type: "string",
|
|
53
|
+
description: "Maximum recording duration in seconds",
|
|
54
|
+
default: String(DEFAULT_MAX_SECONDS),
|
|
55
|
+
},
|
|
56
|
+
debug: {
|
|
57
|
+
type: "boolean",
|
|
58
|
+
description: "Trace engine subprocess calls on stderr (or KESHA_DEBUG=1)",
|
|
59
|
+
default: false,
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
async run({ args }) {
|
|
63
|
+
if (args.debug) log.debugEnabled = true;
|
|
64
|
+
const resolved = resolveRecordArgs(args as RecordArgs);
|
|
65
|
+
if (!resolved.ok) {
|
|
66
|
+
log.error(resolved.error);
|
|
67
|
+
process.exit(2);
|
|
68
|
+
}
|
|
69
|
+
await recordEngine(resolved.out, resolved.maxSeconds);
|
|
70
|
+
},
|
|
71
|
+
});
|
package/src/cli.ts
CHANGED
|
@@ -7,6 +7,8 @@ export { doctorCommand } from "./cli/doctor";
|
|
|
7
7
|
export { completionsCommand } from "./cli/completions";
|
|
8
8
|
export { installCommand } from "./cli/install";
|
|
9
9
|
export { manpageCommand } from "./cli/manpage";
|
|
10
|
+
export { recordCommand, resolveRecordArgs } from "./cli/record";
|
|
11
|
+
export type { ResolvedRecordArgs } from "./cli/record";
|
|
10
12
|
export { sayCommand } from "./cli/say";
|
|
11
13
|
export { pickVoiceForLang } from "./voice-routing";
|
|
12
14
|
export { statusCommand } from "./cli/status";
|
|
@@ -17,6 +19,7 @@ export {
|
|
|
17
19
|
detectLanguage,
|
|
18
20
|
checkLanguageMismatch,
|
|
19
21
|
resolveOutputFormat,
|
|
22
|
+
shouldReportTranscribeProgress,
|
|
20
23
|
} from "./cli/main";
|
|
21
24
|
export type { ResolvedOutputFormat } from "./cli/main";
|
|
22
25
|
export { runCli } from "./cli/dispatch";
|
package/src/engine.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync, statSync } from "fs";
|
|
|
2
2
|
import { join } from "path";
|
|
3
3
|
import { log } from "./log";
|
|
4
4
|
import { defaultEngineBinPath, keshaCacheDir } from "./paths";
|
|
5
|
+
import { engineAbortError, registerProcessTree } from "./process-tree";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Capability-flag string surfaced via `kesha-engine --capabilities-json`. Single
|
|
@@ -96,26 +97,57 @@ export function spawnStdioWithDebugFd(
|
|
|
96
97
|
return out as [SpawnStdioEntry, SpawnStdioEntry, SpawnStdioEntry, ...SpawnStdioEntry[]];
|
|
97
98
|
}
|
|
98
99
|
|
|
99
|
-
|
|
100
|
+
export interface RunEngineOptions {
|
|
101
|
+
signal?: AbortSignal;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function runEngine(
|
|
105
|
+
args: string[],
|
|
106
|
+
opts: RunEngineOptions = {},
|
|
107
|
+
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
|
108
|
+
if (opts.signal?.aborted) throw engineAbortError();
|
|
100
109
|
const binPath = getEngineBinPath();
|
|
101
110
|
const startedAt = performance.now();
|
|
102
111
|
log.debug(`spawn ${binPath} ${args.join(" ")}`);
|
|
103
112
|
const proc = Bun.spawn([binPath, ...args], {
|
|
113
|
+
detached: true,
|
|
104
114
|
stdio: spawnStdioWithDebugFd(["ignore", "pipe", "pipe"]),
|
|
105
115
|
});
|
|
116
|
+
const tree = registerProcessTree(proc);
|
|
117
|
+
let aborted = false;
|
|
118
|
+
let forceKillTimer: Timer | undefined;
|
|
119
|
+
const abort = () => {
|
|
120
|
+
aborted = true;
|
|
121
|
+
tree.terminate("SIGTERM");
|
|
122
|
+
forceKillTimer ??= tree.forceKillAfterGrace();
|
|
123
|
+
};
|
|
124
|
+
opts.signal?.addEventListener("abort", abort, { once: true });
|
|
106
125
|
// `stdio: [...]` widens stdout/stderr into a union; indices 1/2 are
|
|
107
126
|
// pinned to "pipe" by the helper, so the narrow ReadableStream type
|
|
108
127
|
// is correct. Cast to drop the spurious `number` arm.
|
|
109
128
|
const stdoutStream = proc.stdout as ReadableStream<Uint8Array>;
|
|
110
129
|
const stderrStream = proc.stderr as ReadableStream<Uint8Array>;
|
|
111
130
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
131
|
+
let stdout: string;
|
|
132
|
+
let stderr: string;
|
|
133
|
+
let exitCode: number;
|
|
134
|
+
try {
|
|
135
|
+
[stdout, stderr, exitCode] = await Promise.all([
|
|
136
|
+
new Response(stdoutStream).text(),
|
|
137
|
+
new Response(stderrStream).text(),
|
|
138
|
+
proc.exited,
|
|
139
|
+
]);
|
|
140
|
+
} finally {
|
|
141
|
+
opts.signal?.removeEventListener("abort", abort);
|
|
142
|
+
tree.dispose();
|
|
143
|
+
if (!aborted && forceKillTimer) clearTimeout(forceKillTimer);
|
|
144
|
+
}
|
|
117
145
|
|
|
118
146
|
log.debug(`exit=${exitCode} dt=${Math.round(performance.now() - startedAt)}ms args=${JSON.stringify(args)}`);
|
|
147
|
+
if (aborted) {
|
|
148
|
+
log.debug(`aborted args=${JSON.stringify(args)}`);
|
|
149
|
+
throw engineAbortError();
|
|
150
|
+
}
|
|
119
151
|
|
|
120
152
|
// #275 D4: surface engine stderr on the success path so warnings like
|
|
121
153
|
// `hint: audio is 180s`, `Model mirror active:`, and the dtrace lines
|
|
@@ -131,12 +163,13 @@ async function runEngine(args: string[]): Promise<{ stdout: string; stderr: stri
|
|
|
131
163
|
/** VAD preprocessing selector.
|
|
132
164
|
* - `"auto"` (default): engine decides — VAD when audio ≥ 120 s and model installed
|
|
133
165
|
* - `"on"`: force VAD (requires `kesha install --vad`)
|
|
134
|
-
* - `"off"`: force full-file
|
|
166
|
+
* - `"off"`: force full-file ASR for short/medium files; long audio fails early
|
|
135
167
|
*/
|
|
136
168
|
export type VadMode = "auto" | "on" | "off";
|
|
137
169
|
|
|
138
170
|
export interface TranscribeEngineOptions {
|
|
139
171
|
vad?: VadMode;
|
|
172
|
+
signal?: AbortSignal;
|
|
140
173
|
/** Request speaker labels in transcript segments. Requires the engine to
|
|
141
174
|
* advertise `transcribe.diarize` (darwin-arm64 only — see #199). */
|
|
142
175
|
speakers?: boolean;
|
|
@@ -197,7 +230,7 @@ export async function transcribeEngine(
|
|
|
197
230
|
const args = ["transcribe", audioPath];
|
|
198
231
|
if (opts.vad === "on") args.push("--vad");
|
|
199
232
|
else if (opts.vad === "off") args.push("--no-vad");
|
|
200
|
-
const { stdout, stderr, exitCode } = await runEngine(args);
|
|
233
|
+
const { stdout, stderr, exitCode } = await runEngine(args, { signal: opts.signal });
|
|
201
234
|
if (exitCode !== 0) {
|
|
202
235
|
throw new Error(stderr || `kesha-engine exited with code ${exitCode}`);
|
|
203
236
|
}
|
|
@@ -239,7 +272,7 @@ export async function transcribeEngineWithSegments(
|
|
|
239
272
|
if (opts.speakers) {
|
|
240
273
|
args.push("--speakers");
|
|
241
274
|
}
|
|
242
|
-
const { stdout, stderr, exitCode } = await runEngine(args);
|
|
275
|
+
const { stdout, stderr, exitCode } = await runEngine(args, { signal: opts.signal });
|
|
243
276
|
if (exitCode !== 0) {
|
|
244
277
|
throw new Error(stderr || `kesha-engine exited with code ${exitCode}`);
|
|
245
278
|
}
|
|
@@ -251,6 +284,28 @@ export async function transcribeEngineWithSegments(
|
|
|
251
284
|
}
|
|
252
285
|
}
|
|
253
286
|
|
|
287
|
+
export async function recordEngine(outPath: string, maxSeconds: number): Promise<void> {
|
|
288
|
+
const binPath = getEngineBinPath();
|
|
289
|
+
const args = ["record", "--out", outPath, "--max-seconds", String(maxSeconds)];
|
|
290
|
+
const startedAt = performance.now();
|
|
291
|
+
log.debug(`spawn ${binPath} ${args.join(" ")}`);
|
|
292
|
+
const proc = Bun.spawn([binPath, ...args], {
|
|
293
|
+
detached: true,
|
|
294
|
+
stdio: spawnStdioWithDebugFd(["inherit", "inherit", "inherit"]),
|
|
295
|
+
});
|
|
296
|
+
const tree = registerProcessTree(proc);
|
|
297
|
+
let exitCode: number;
|
|
298
|
+
try {
|
|
299
|
+
exitCode = await proc.exited;
|
|
300
|
+
} finally {
|
|
301
|
+
tree.dispose();
|
|
302
|
+
}
|
|
303
|
+
log.debug(`exit=${exitCode} dt=${Math.round(performance.now() - startedAt)}ms args=${JSON.stringify(args)}`);
|
|
304
|
+
if (exitCode !== 0) {
|
|
305
|
+
throw new Error(`kesha-engine record exited with code ${exitCode}`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
254
309
|
export function parseLangResult(stdout: string): LangDetectResult | null {
|
|
255
310
|
try {
|
|
256
311
|
const parsed = JSON.parse(stdout);
|
|
@@ -263,17 +318,23 @@ export function parseLangResult(stdout: string): LangDetectResult | null {
|
|
|
263
318
|
}
|
|
264
319
|
}
|
|
265
320
|
|
|
266
|
-
export async function detectAudioLanguageEngine(
|
|
321
|
+
export async function detectAudioLanguageEngine(
|
|
322
|
+
audioPath: string,
|
|
323
|
+
opts: RunEngineOptions = {},
|
|
324
|
+
): Promise<LangDetectResult | null> {
|
|
267
325
|
if (!isEngineInstalled()) return null;
|
|
268
|
-
const { stdout, exitCode } = await runEngine(["detect-lang", audioPath]);
|
|
326
|
+
const { stdout, exitCode } = await runEngine(["detect-lang", audioPath], opts);
|
|
269
327
|
if (exitCode !== 0) return null;
|
|
270
328
|
return parseLangResult(stdout);
|
|
271
329
|
}
|
|
272
330
|
|
|
273
|
-
export async function detectTextLanguageEngine(
|
|
331
|
+
export async function detectTextLanguageEngine(
|
|
332
|
+
text: string,
|
|
333
|
+
opts: RunEngineOptions = {},
|
|
334
|
+
): Promise<LangDetectResult | null> {
|
|
274
335
|
if (text.trim().length === 0) return null;
|
|
275
336
|
if (!isEngineInstalled()) return null;
|
|
276
|
-
const { stdout, exitCode } = await runEngine(["detect-text-lang", text]);
|
|
337
|
+
const { stdout, exitCode } = await runEngine(["detect-text-lang", text], opts);
|
|
277
338
|
if (exitCode !== 0) return null;
|
|
278
339
|
return parseLangResult(stdout);
|
|
279
340
|
}
|
package/src/log.ts
CHANGED
|
@@ -34,8 +34,11 @@ export const log = {
|
|
|
34
34
|
error: (msg: string) => console.error(pc.red(msg)),
|
|
35
35
|
|
|
36
36
|
debugEnabled: false,
|
|
37
|
+
isDebugEnabled(): boolean {
|
|
38
|
+
return this.debugEnabled || envDebug();
|
|
39
|
+
},
|
|
37
40
|
debug(msg: string): void {
|
|
38
|
-
if (this.
|
|
41
|
+
if (this.isDebugEnabled()) {
|
|
39
42
|
// `[debug +Nms]` prefix sits on the CLI process's own timeline so
|
|
40
43
|
// the reader can see when each line fired. The Rust engine emits
|
|
41
44
|
// the same `+Nms` shape from `rust/src/debug.rs::trace_fmt`, but
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
type ManagedSignal = "SIGINT" | "SIGTERM" | "SIGKILL";
|
|
2
|
+
|
|
3
|
+
interface KillableProcess {
|
|
4
|
+
pid: number;
|
|
5
|
+
kill(signal?: ManagedSignal): void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface ActiveProcess {
|
|
9
|
+
pid: number;
|
|
10
|
+
kill(signal?: ManagedSignal): void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const FORCE_KILL_GRACE_MS = 1_000;
|
|
14
|
+
const SIGNAL_EXIT_BUFFER_MS = 50;
|
|
15
|
+
const activeProcesses = new Set<ActiveProcess>();
|
|
16
|
+
let signalHandlersInstalled = false;
|
|
17
|
+
let pendingSignalCleanup:
|
|
18
|
+
| {
|
|
19
|
+
exitCode: number;
|
|
20
|
+
done: Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
| null = null;
|
|
23
|
+
|
|
24
|
+
export function engineAbortError(): Error {
|
|
25
|
+
const err = new Error("kesha-engine process aborted");
|
|
26
|
+
err.name = "AbortError";
|
|
27
|
+
return err;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function registerProcessTree(proc: KillableProcess): {
|
|
31
|
+
dispose: () => void;
|
|
32
|
+
terminate: (signal?: ManagedSignal) => void;
|
|
33
|
+
forceKillAfterGrace: () => Timer;
|
|
34
|
+
} {
|
|
35
|
+
const active: ActiveProcess = {
|
|
36
|
+
pid: proc.pid,
|
|
37
|
+
kill: (signal?: ManagedSignal) => terminateProcessTree(proc, signal),
|
|
38
|
+
};
|
|
39
|
+
activeProcesses.add(active);
|
|
40
|
+
ensureSignalHandlers();
|
|
41
|
+
return {
|
|
42
|
+
dispose: () => {
|
|
43
|
+
activeProcesses.delete(active);
|
|
44
|
+
},
|
|
45
|
+
terminate: (signal: ManagedSignal = "SIGTERM") => active.kill(signal),
|
|
46
|
+
forceKillAfterGrace: () => scheduleForceKill(active),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function getPendingSignalExitCode(): number | null {
|
|
51
|
+
return pendingSignalCleanup?.exitCode ?? null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function waitForPendingSignalCleanup(): Promise<number | null> {
|
|
55
|
+
if (!pendingSignalCleanup) return null;
|
|
56
|
+
await pendingSignalCleanup.done;
|
|
57
|
+
return pendingSignalCleanup.exitCode;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function terminateProcessTree(proc: KillableProcess, signal: ManagedSignal = "SIGTERM"): void {
|
|
61
|
+
if (!Number.isFinite(proc.pid) || proc.pid <= 0) {
|
|
62
|
+
safeKillDirect(proc, signal);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (process.platform === "win32") {
|
|
67
|
+
const args = ["/PID", String(proc.pid), "/T"];
|
|
68
|
+
if (signal === "SIGKILL") args.push("/F");
|
|
69
|
+
try {
|
|
70
|
+
Bun.spawn(["taskkill", ...args], {
|
|
71
|
+
stdout: "ignore",
|
|
72
|
+
stderr: "ignore",
|
|
73
|
+
});
|
|
74
|
+
return;
|
|
75
|
+
} catch {
|
|
76
|
+
safeKillDirect(proc, signal);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
process.kill(-proc.pid, signal);
|
|
83
|
+
} catch {
|
|
84
|
+
safeKillDirect(proc, signal);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function safeKillDirect(proc: KillableProcess, signal: ManagedSignal): void {
|
|
89
|
+
try {
|
|
90
|
+
proc.kill(signal);
|
|
91
|
+
} catch {
|
|
92
|
+
// The process may already have exited between the caller deciding to clean
|
|
93
|
+
// it up and the signal reaching the kernel.
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function scheduleForceKill(proc: ActiveProcess, opts: { ref?: boolean } = {}): Timer {
|
|
98
|
+
const timer = setTimeout(() => proc.kill("SIGKILL"), FORCE_KILL_GRACE_MS);
|
|
99
|
+
if (opts.ref !== true) timer.unref?.();
|
|
100
|
+
return timer;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function ensureSignalHandlers(): void {
|
|
104
|
+
if (signalHandlersInstalled) return;
|
|
105
|
+
signalHandlersInstalled = true;
|
|
106
|
+
process.on("SIGINT", () => terminateActiveProcessTrees("SIGINT", 130));
|
|
107
|
+
process.on("SIGTERM", () => terminateActiveProcessTrees("SIGTERM", 143));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function terminateActiveProcessTrees(signal: ManagedSignal, exitCode: number): void {
|
|
111
|
+
const processes = [...activeProcesses];
|
|
112
|
+
process.exitCode = exitCode;
|
|
113
|
+
|
|
114
|
+
for (const proc of processes) {
|
|
115
|
+
proc.kill(signal);
|
|
116
|
+
scheduleForceKill(proc, { ref: true });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (pendingSignalCleanup) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const delayMs = processes.length > 0
|
|
124
|
+
? FORCE_KILL_GRACE_MS + SIGNAL_EXIT_BUFFER_MS
|
|
125
|
+
: SIGNAL_EXIT_BUFFER_MS;
|
|
126
|
+
let resolveDone!: () => void;
|
|
127
|
+
const done = new Promise<void>((resolve) => {
|
|
128
|
+
resolveDone = resolve;
|
|
129
|
+
});
|
|
130
|
+
pendingSignalCleanup = { exitCode, done };
|
|
131
|
+
setTimeout(resolveDone, delayMs);
|
|
132
|
+
done.then(() => process.exit(exitCode));
|
|
133
|
+
}
|
package/src/shell-artifacts.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { doctorCommand } from "./cli/doctor";
|
|
|
4
4
|
import { installCommand } from "./cli/install";
|
|
5
5
|
import { mainCommand } from "./cli/main";
|
|
6
6
|
import { manpageCommand } from "./cli/manpage";
|
|
7
|
+
import { recordCommand } from "./cli/record";
|
|
7
8
|
import { sayCommand } from "./cli/say";
|
|
8
9
|
import { statsCommand } from "./cli/stats";
|
|
9
10
|
import { statusCommand } from "./cli/status";
|
|
@@ -45,6 +46,7 @@ const CLI_COMMANDS: CliCommand[] = [
|
|
|
45
46
|
{ name: "doctor", command: doctorCommand },
|
|
46
47
|
{ name: "install", command: installCommand },
|
|
47
48
|
{ name: "manpage", command: manpageCommand },
|
|
49
|
+
{ name: "record", command: recordCommand },
|
|
48
50
|
{ name: "say", command: sayCommand },
|
|
49
51
|
{ name: "stats", command: statsCommand },
|
|
50
52
|
{ name: "status", command: statusCommand },
|
|
@@ -154,7 +156,6 @@ ${cases}
|
|
|
154
156
|
}
|
|
155
157
|
|
|
156
158
|
complete -F _kesha_completion kesha
|
|
157
|
-
complete -F _kesha_completion parakeet
|
|
158
159
|
`;
|
|
159
160
|
}
|
|
160
161
|
|
|
@@ -183,7 +184,7 @@ function renderZsh(model: ArtifactModel): string {
|
|
|
183
184
|
})
|
|
184
185
|
.join("\n");
|
|
185
186
|
|
|
186
|
-
return `#compdef kesha
|
|
187
|
+
return `#compdef kesha
|
|
187
188
|
# zsh completion for kesha.
|
|
188
189
|
|
|
189
190
|
_kesha() {
|
|
@@ -225,24 +226,21 @@ function renderFishOption(bin: string, condition: string, option: CliOption): st
|
|
|
225
226
|
}
|
|
226
227
|
|
|
227
228
|
function renderFish(model: ArtifactModel): string {
|
|
228
|
-
const bins = ["kesha", "parakeet"];
|
|
229
229
|
const lines = ["# fish completion for kesha."];
|
|
230
230
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
lines.push(renderFishOption(bin, condition, option));
|
|
245
|
-
}
|
|
231
|
+
lines.push("complete -c kesha -f");
|
|
232
|
+
for (const command of model.commands) {
|
|
233
|
+
lines.push(
|
|
234
|
+
`complete -c kesha -n '__fish_use_subcommand' -a '${command.name}' -d '${fishEscape(command.description)}'`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
for (const option of [...ROOT_BUILT_INS, ...model.root.options]) {
|
|
238
|
+
lines.push(renderFishOption("kesha", " -n '__fish_use_subcommand'", option));
|
|
239
|
+
}
|
|
240
|
+
for (const command of model.commands) {
|
|
241
|
+
const condition = ` -n '__fish_seen_subcommand_from ${command.name}'`;
|
|
242
|
+
for (const option of [...HELP_OPTIONS, ...command.options]) {
|
|
243
|
+
lines.push(renderFishOption("kesha", condition, option));
|
|
246
244
|
}
|
|
247
245
|
}
|
|
248
246
|
|
package/src/synth.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
type EngineCapabilities,
|
|
7
7
|
} from "./engine";
|
|
8
8
|
import { log } from "./log";
|
|
9
|
+
import { registerProcessTree } from "./process-tree";
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Wire format for the synthesized audio. Matches the engine's `--format` flag.
|
|
@@ -125,8 +126,10 @@ export async function say(opts: SayOptions): Promise<Uint8Array> {
|
|
|
125
126
|
const startedAt = performance.now();
|
|
126
127
|
log.debug(`spawn ${getEngineBinPath()} ${args.join(" ")} (text: ${opts.text?.length ?? 0} chars)`);
|
|
127
128
|
const proc = Bun.spawn([getEngineBinPath(), ...args], {
|
|
129
|
+
detached: true,
|
|
128
130
|
stdio: spawnStdioWithDebugFd(["pipe", "pipe", "pipe"]),
|
|
129
131
|
});
|
|
132
|
+
const tree = registerProcessTree(proc);
|
|
130
133
|
// The `stdio: [...]` form widens stdin/stdout/stderr into a union
|
|
131
134
|
// (Bun.spawn can't infer that indices 0/1/2 are always "pipe" given
|
|
132
135
|
// the helper's return type). All three are guaranteed `FileSink` /
|
|
@@ -143,11 +146,18 @@ export async function say(opts: SayOptions): Promise<Uint8Array> {
|
|
|
143
146
|
await stdin.end();
|
|
144
147
|
}
|
|
145
148
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
149
|
+
let stdoutBuf: ArrayBuffer;
|
|
150
|
+
let stderrText: string;
|
|
151
|
+
let exitCode: number;
|
|
152
|
+
try {
|
|
153
|
+
[stdoutBuf, stderrText, exitCode] = await Promise.all([
|
|
154
|
+
new Response(stdout).arrayBuffer(),
|
|
155
|
+
new Response(stderr).text(),
|
|
156
|
+
proc.exited,
|
|
157
|
+
]);
|
|
158
|
+
} finally {
|
|
159
|
+
tree.dispose();
|
|
160
|
+
}
|
|
151
161
|
|
|
152
162
|
log.debug(`exit=${exitCode} dt=${Math.round(performance.now() - startedAt)}ms bytes=${stdoutBuf.byteLength}`);
|
|
153
163
|
|
package/src/transcribe.ts
CHANGED
|
@@ -14,6 +14,8 @@ export interface TranscribeOptions {
|
|
|
14
14
|
silent?: boolean;
|
|
15
15
|
/** Silero VAD preprocessing selector. Defaults to `"auto"`. */
|
|
16
16
|
vad?: VadMode;
|
|
17
|
+
/** Cancel any in-flight engine subprocess for this transcription. */
|
|
18
|
+
signal?: AbortSignal;
|
|
17
19
|
/** Request timestamped transcript segments from the engine. */
|
|
18
20
|
timestamps?: boolean;
|
|
19
21
|
/** Request speaker labels in transcript segments (#199). Implies `timestamps`.
|
|
@@ -56,10 +58,11 @@ export async function transcribeWithSegments(
|
|
|
56
58
|
if (opts.timestamps || opts.speakers) {
|
|
57
59
|
return transcribeEngineWithSegments(audioPath, {
|
|
58
60
|
vad: opts.vad,
|
|
61
|
+
signal: opts.signal,
|
|
59
62
|
speakers: opts.speakers,
|
|
60
63
|
});
|
|
61
64
|
}
|
|
62
65
|
|
|
63
|
-
const text = await transcribeEngine(audioPath, { vad: opts.vad });
|
|
66
|
+
const text = await transcribeEngine(audioPath, { vad: opts.vad, signal: opts.signal });
|
|
64
67
|
return { text, segments: [] };
|
|
65
68
|
}
|