@drakulavich/kesha-voice-kit 1.18.2 → 1.18.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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>Open-source voice toolkit.</b> Optimized for Apple Silicon (CoreML), with ONNX fallback on supported Linux/Windows builds.<br>A collection of small, fast, open-source audio models — packaged as CLI tools and an <a href="https://github.com/openclaw/openclaw">OpenClaw</a> skill for LLM agents.</p>
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
- - **Speech-to-text** — 25 languages, ~15x faster than Whisper on Apple Silicon, ~2.5x on CPU
17
- - **Text-to-speech** — Kokoro (EN) + Vosk-TTS (RU) + macOS system voices, SSML preview
18
- - **Rust engine** — single 20MB binary, no ffmpeg, no Python, no native Node addons
19
- - **OpenClaw-ready** — plug into your LLM agent as a voice processing skill
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. Also available as `parakeet` command (backward-compatible alias).
212
+ Stdout: transcript. Stderr: errors. Pipe-friendly.
213
213
 
214
- For long / silence-heavy audio, use `--vad` (auto-on past 120 s). Details: [docs/vad.md](docs/vad.md).
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
- > **~15x faster than Whisper** on Apple Silicon (M3 Pro), **~2.5x faster** on CPU
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
- ![Benchmark: openai-whisper vs faster-whisper vs Kesha Voice Kit](assets/benchmark.svg)
274
+ ![Benchmark: openai-whisper vs faster-whisper vs Kesha Voice Kit](docs/assets/benchmark.svg)
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
@@ -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
@@ -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 'Disable VAD preprocessing regardless of duration or install state'
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'
@@ -1,4 +1,4 @@
1
- #compdef kesha parakeet
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[Disable VAD preprocessing regardless of duration or install state]'
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[Disable VAD preprocessing regardless of duration or install state]'
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.2" "User Commands"
1
+ .TH KESHA 1 "May 2026" "kesha 1.18.3" "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
- Disable VAD preprocessing regardless of duration or install state
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.2",
3
+ "version": "1.18.3",
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",
@@ -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 = ["doctor", "install", "status", "say", "stats", "support-bundle", "completions", "manpage"];
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: "Disable VAD preprocessing regardless of duration or install state",
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 = process.stderr.isTTY === true || process.stdout.isTTY !== true;
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
- const [audioResult, transcript] = await Promise.all([
274
- wantsLangId
275
- ? stats.timeStage("lang_id_audio", () => detectAudioLanguageEngine(file))
276
- : Promise.resolve(null),
277
- stats.timeStage("transcribe", () =>
278
- transcribeWithSegments(file, { vad: vadMode, timestamps: args.timestamps, speakers: args.speakers })
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) process.exit(1);
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
- async function runEngine(args: string[]): Promise<{ stdout: string; stderr: string; exitCode: number }> {
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
- const [stdout, stderr, exitCode] = await Promise.all([
113
- new Response(stdoutStream).text(),
114
- new Response(stderrStream).text(),
115
- proc.exited,
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 pass regardless of duration or install state
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(audioPath: string): Promise<LangDetectResult | null> {
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(text: string): Promise<LangDetectResult | null> {
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.debugEnabled || envDebug()) {
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
+ }
@@ -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 parakeet
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
- for (const bin of bins) {
232
- lines.push(`complete -c ${bin} -f`);
233
- for (const command of model.commands) {
234
- lines.push(
235
- `complete -c ${bin} -n '__fish_use_subcommand' -a '${command.name}' -d '${fishEscape(command.description)}'`,
236
- );
237
- }
238
- for (const option of [...ROOT_BUILT_INS, ...model.root.options]) {
239
- lines.push(renderFishOption(bin, " -n '__fish_use_subcommand'", option));
240
- }
241
- for (const command of model.commands) {
242
- const condition = ` -n '__fish_seen_subcommand_from ${command.name}'`;
243
- for (const option of [...HELP_OPTIONS, ...command.options]) {
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
- const [stdoutBuf, stderrText, exitCode] = await Promise.all([
147
- new Response(stdout).arrayBuffer(),
148
- new Response(stderr).text(),
149
- proc.exited,
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
  }