@drakulavich/kesha-voice-kit 1.11.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -32,6 +32,40 @@ kesha audio.ogg # transcript to stdout
32
32
 
33
33
  Air-gapped or behind a corporate mirror? See [docs/model-mirror.md](docs/model-mirror.md).
34
34
 
35
+ ## Nix Install (Recommended)
36
+
37
+ **Prerequisites:** [Nix](https://nixos.org/download/) with flakes enabled.
38
+
39
+ ### One-liner run (no install)
40
+ ```bash
41
+ nix run github:drakulavich/kesha-voice-kit -- install
42
+ nix run github:drakulavich/kesha-voice-kit -- audio.ogg
43
+ ```
44
+
45
+ ### Install to profile (persistent)
46
+ ```bash
47
+ nix profile install github:drakulavich/kesha-voice-kit
48
+ kesha install # downloads engine + models
49
+ kesha audio.ogg # transcript to stdout
50
+ ```
51
+
52
+ ### Development shell
53
+ ```bash
54
+ nix develop github:drakulavich/kesha-voice-kit
55
+ # Now you have: rust, cargo, bun, protoc, cargo-make
56
+ ```
57
+
58
+ ### Build only
59
+ ```bash
60
+ nix build github:drakulavich/kesha-voice-kit#kesha-engine
61
+ ./result/bin/kesha-engine --help
62
+ ```
63
+
64
+ **Why Nix?**
65
+ - ✅ Reproducible builds across Linux/macOS
66
+ - ✅ All native deps (onnxruntime, protobuf, abseil) handled automatically
67
+ - ✅ No "works on my machine" — same `flake.nix` = identical results everywhere
68
+
35
69
  ## Speech-to-text
36
70
 
37
71
  ```bash
@@ -60,52 +94,29 @@ Stdout: transcript. Stderr: errors. Pipe-friendly. Also available as `parakeet`
60
94
 
61
95
  For long / silence-heavy audio, use `--vad` (auto-on past 120 s). Details: [docs/vad.md](docs/vad.md).
62
96
 
63
- ## Text-to-speech
64
-
65
- Kesha speaks back via Kokoro-82M (English) and Vosk-TTS (Russian) — voice auto-picks from the text's language:
97
+ **Speaker diarization** (darwin-arm64, post-v1.12.0):
66
98
 
67
99
  ```bash
68
- kesha install --tts # ~990MB (Kokoro + Vosk-TTS RU, opt-in)
69
- kesha say "Hello, world" > hello.wav
70
- kesha say "Привет, мир" > privet.wav # auto-routes (Milena on darwin, ru-vosk-m02 elsewhere)
100
+ kesha install --diarize # one-time, ~245MB Sortformer model
101
+ kesha --json --vad --speakers meeting.m4a > out.json
102
+ jq '.[0].segments[] | "\(.speaker)\t\(.text)"' out.json
71
103
  ```
72
104
 
73
- **Russian abbreviations** (`ru-vosk-*` voices):
105
+ Each segment gets a `speaker` integer (cluster ID, stable within one file). Linux / Windows: `--speakers` returns a clear "currently darwin-arm64 only" error — see [#199](https://github.com/drakulavich/kesha-voice-kit/issues/199).
74
106
 
75
- ```bash
76
- # Auto-detect on by default — ФСБ reads as "эф-эс-бэ"
77
- kesha say --voice ru-vosk-m02 'ФСБ объявила решение.'
78
-
79
- # Force a literal reading (Vosk reads as "фсб")
80
- kesha say --voice ru-vosk-m02 --no-expand-abbrev 'ФСБ.'
81
-
82
- # Explicit SSML control (overrides the rule and the stop-list)
83
- kesha say --voice ru-vosk-m02 --ssml \
84
- '<speak><say-as interpret-as="characters">КОТ</say-as></speak>'
85
- ```
86
-
87
- Detection rule: auto-expand fires when the token cannot be pronounced as a natural Russian syllable — length ≤ 2 (ИП, ЕС), 0 vowels (ФСБ, СНГ), 2+ consecutive vowels (ОАЭ), or 2+ consecutive consonants (США, ЦСКА). Tokens with strict CVC/CVCV alternation pass through (ВОЗ → "воз", НАТО → "нато", ОПЕК → "опек"). Small stop-list for common short words (ОН, МЫ, ВЫ, КАК, ЧТО, …) and Ъ/Ь-containing tokens are always skipped. See [#232](https://github.com/drakulavich/kesha-voice-kit/issues/232).
107
+ ## Text-to-speech
88
108
 
89
- **English acronyms** (`en-*` voices, Kokoro-82M):
109
+ Kesha speaks back via Kokoro-82M (English) and Vosk-TTS (Russian) — voice auto-picks from the text's language:
90
110
 
91
111
  ```bash
92
- # Auto-expand on by default — FBI reads as "ef bee eye"
93
- kesha say --voice en-am_michael 'The FBI is investigating.'
94
-
95
- # Force a literal reading (Kokoro fuses unknown caps tokens to one syllable)
96
- kesha say --voice en-am_michael --no-expand-abbrev 'FBI.'
97
-
98
- # Explicit SSML control (overrides the rule and the stop-list)
99
- kesha say --voice en-am_michael --ssml \
100
- '<speak><say-as interpret-as="characters">NASA</say-as></speak>'
112
+ kesha install --tts # ~990MB (Kokoro + Vosk-TTS RU, opt-in)
113
+ kesha say "Hello, world" > hello.wav
114
+ kesha say "Привет, мир" > privet.wav # auto-routes (Milena on darwin, ru-vosk-m02 elsewhere)
101
115
  ```
102
116
 
103
- Three-table mechanism:
104
- - **Letter-spell** — uppercase Latin tokens 2–5 chars (no digits, no mixed case) get expanded letter-by-letter (FBI → "ef bee eye"). Disable with `--no-expand-abbrev`.
105
- - **`STOP_LIST`** (30 entries) — natural-English caps words pass through unchanged: NASA, NATO, AIDS, OPEC, IKEA, ASCII, NAFTA, LASER, RADAR, SCUBA + emphatic length-2 caps (OK, NO, GO, IT, IS, AS, AT, BY, IN, ON, OR, OF, TO, WE, US, MY, ME, HE, BE, DO).
106
- - **`IPA_LEXICON`** (20 entries) — case-sensitive token → IPA-phoneme map that bypasses G2P entirely. Covers all-caps acronyms with industry pronunciations (EPAM, JSON, JPEG, SQL, ASAP, GIF, CRUD, JWT) AND mixed-case proper nouns (Anthropic, Microsoft, Claude, NVIDIA, Kubernetes, PostgreSQL, GraphQL, Linux, Tokio, macOS, Granola, OAuth). IPA hits fire even with `--no-expand-abbrev` — they're intent-explicit, parallel to `<say-as>`.
117
+ **Russian abbreviations** (`ru-vosk-*`): all-uppercase Cyrillic 2-5-char tokens auto-expand letter-by-letter when not pronounceable as a Russian syllable (ФСБ → "эф-эс-бэ", ВОЗ → "воз"). Disable with `--no-expand-abbrev`. See [docs/tts.md#russian-abbreviation-auto-expansion](docs/tts.md#russian-abbreviation-auto-expansion).
107
118
 
108
- Override per-token via SSML `<say-as interpret-as="characters">…</say-as>` (always letter-spells via the table). See [#244](https://github.com/drakulavich/kesha-voice-kit/issues/244).
119
+ **English acronyms** (`en-*`, Kokoro): three-table mechanism (letter-spell rule + STOP_LIST + IPA_LEXICON) auto-expands FBI → "ef bee eye" and gives EPAM/JSON/Anthropic the right IPA. Disable letter-spell with `--no-expand-abbrev`. See [docs/tts.md#english-acronym-auto-expansion](docs/tts.md#english-acronym-auto-expansion).
109
120
 
110
121
  **Russian word stress** (`ru-vosk-*` voices):
111
122
 
@@ -121,6 +132,18 @@ kesha say --voice ru-vosk-m02 --ssml \
121
132
 
122
133
  Vosk-TTS 0.9-multi honors a `+` placed BEFORE the target stressed vowel — but only when the marker shifts stress AWAY from the model's default (first-syllable). `+` agreeing with the default is a no-op. See [#233](https://github.com/drakulavich/kesha-voice-kit/issues/233).
123
134
 
135
+ **Speech rate via SSML** (`ru-vosk-*` and `en-*` voices):
136
+
137
+ ```bash
138
+ kesha say --voice ru-vosk-m02 --ssml \
139
+ '<speak><prosody rate="slow">Привет, как дела.</prosody></speak>' --out slow.wav
140
+
141
+ kesha say --voice en-am_michael --ssml \
142
+ '<speak><prosody rate="x-fast">Read this fast.</prosody></speak>' --out fast.wav
143
+ ```
144
+
145
+ Honored when `<prosody rate>` wraps the whole utterance. Mid-utterance prosody warns and synthesizes at default rate (whole-segment-only is a v1 limitation; mid-utterance support tracked in [#236](https://github.com/drakulavich/kesha-voice-kit/issues/236)). `--rate` and `<prosody rate>` compose multiplicatively. Range clamped to 0.5×–2.0×.
146
+
124
147
  macOS system voices, SSML, voice listing, and the full voice catalogue: [docs/tts.md](docs/tts.md).
125
148
 
126
149
  ## Performance
package/SKILL.md CHANGED
@@ -54,6 +54,16 @@ jq '.[0].segments' voice.timestamps.json
54
54
 
55
55
  Each segment has `start`, `end`, and `text` fields. `--timestamps` is available for machine-readable output (`--json`, `--toon`, or `--format json`).
56
56
 
57
+ **Speaker diarization** (darwin-arm64, post-v1.12.0). Add `--speakers` to label each segment with a cluster ID — useful for transcribing multi-person calls / meetings:
58
+
59
+ ```bash
60
+ kesha install --diarize # one-time, ~245MB
61
+ kesha --json --vad --speakers meeting.m4a > out.json
62
+ jq '.[0].segments[] | "\(.speaker)\t\(.text)"' out.json
63
+ ```
64
+
65
+ Each `segment.speaker` is a number (cluster id, stable within one file). On Linux / Windows the engine returns a clear "currently darwin-arm64 only" error — see [#199](https://github.com/drakulavich/kesha-voice-kit/issues/199).
66
+
57
67
  **Formats:** .ogg, .opus, .mp3, .m4a, .wav, .flac, .webm — decoded via symphonia, no ffmpeg required.
58
68
 
59
69
  **Other output modes:**
@@ -84,12 +94,14 @@ kesha say "Hi" --format ogg-opus --bitrate 16000 --out tiny.ogg # tin
84
94
 
85
95
  Format is also inferred from `--out` extension (`.ogg` / `.opus` / `.oga` → OGG/Opus). `--bitrate` (6 000–510 000 bps) and `--sample-rate` (8 000 / 12 000 / 16 000 / 24 000 / 48 000 Hz) tune the encoder.
86
96
 
87
- **Russian abbreviation handling** (`ru-vosk-*` only): all-uppercase Cyrillic tokens (length 2–5) auto-expand letter-by-letter when the token cannot be pronounced as a natural Russian syllable — `ФСБ объявила` → `эф-эс-бэ объявила`. Tokens with strict CVC/CVCV alternation pass through (ВОЗ → "воз", НАТО → "нато"). Disable per call with `--no-expand-abbrev`. Override per-token via SSML `<say-as interpret-as="characters">…</say-as>` (always wins, even with `--no-expand-abbrev`). Stop-list for common short words (ОН, МЫ, ВЫ, КАК, ЧТО) prevents false positives. Closes [#232](https://github.com/drakulavich/kesha-voice-kit/issues/232).
97
+ **Russian abbreviations** (`ru-vosk-*`): all-uppercase Cyrillic 2-5-char tokens auto-expand letter-by-letter when not pronounceable as a Russian syllable (ФСБ → "эф-эс-бэ", ВОЗ → "воз"). Disable with `--no-expand-abbrev`. See [docs/tts.md#russian-abbreviation-auto-expansion](docs/tts.md#russian-abbreviation-auto-expansion).
88
98
 
89
- **English acronym handling** (`en-*` Kokoro-82M only). Three tables: (1) **letter-spell** for uppercase 2–5-char tokens not on the stop-list (FBI → "ef bee eye", HTTP → "aitch tee tee pee"); (2) **`STOP_LIST`** (30 entries) — natural-English caps words pass through (NASA, NATO, AIDS, OPEC, IKEA, ASCII, NAFTA, LASER, RADAR, SCUBA + 20 emphatic length-2 caps); (3) **`IPA_LEXICON`** (20 entries) — case-sensitive token → IPA-phoneme map that bypasses G2P entirely. Covers industry-pronunciation acronyms (EPAM /ˈiːpæm/, JSON /ˈdʒeɪsən/, JPEG, SQL, ASAP, GIF, CRUD, JWT, OAuth) AND mixed-case proper nouns (Anthropic /ænˈθrɒpɪk/, Microsoft, Claude, NVIDIA, Kubernetes, PostgreSQL, GraphQL, Linux, Tokio, macOS, Granola). Disable letter-spell with `--no-expand-abbrev` — IPA hits still fire (intent-explicit). Override per-token via SSML `<say-as interpret-as="characters">…</say-as>` (always wins). Closes [#244](https://github.com/drakulavich/kesha-voice-kit/issues/244).
99
+ **English acronyms** (`en-*`, Kokoro): three-table mechanism (letter-spell rule + STOP_LIST + IPA_LEXICON) auto-expands FBI → "ef bee eye" and gives EPAM/JSON/Anthropic the right IPA. Disable letter-spell with `--no-expand-abbrev`. See [docs/tts.md#english-acronym-auto-expansion](docs/tts.md#english-acronym-auto-expansion).
90
100
 
91
101
  **Russian word stress** (`ru-vosk-*` only): `<emphasis>сл+ово</emphasis>` shifts stress to the vowel marked with `+`. `<emphasis level="none">сл+ово</emphasis>` strips the `+` (cancel inherited emphasis). Other voices (`en-*`, `macos-*`) silently strip the `+` and warn once per process. Auto-stress dictionary not provided — caller writes the `+` manually. Closes [#233](https://github.com/drakulavich/kesha-voice-kit/issues/233).
92
102
 
103
+ **Speech rate via SSML** (`ru-vosk-*` and `en-*` voices): wrap the utterance in `<prosody rate="…">` to slow down or speed up synthesis. Supports SSML named values (`x-slow`/`slow`/`medium`/`fast`/`x-fast`), absolute `N%` (e.g. `120%`), and relative `+N%`/`-N%`. Honored only when `<prosody>` wraps the whole utterance — mid-utterance prosody warns and synthesizes at default rate. `--rate` and `<prosody rate>` compose multiplicatively; result is clamped to 0.5×–2.0×. AVSpeech (`macos-*` voices) does not yet accept SSML — see [#236](https://github.com/drakulavich/kesha-voice-kit/issues/236).
104
+
93
105
  ## Language detection standalone
94
106
 
95
107
  `kesha --json audio.ogg` includes both audio-based (`audioLanguage`) and text-based (`textLanguage`) detection. Use audio detection to identify the language before running language-specific logic.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakulavich/kesha-voice-kit",
3
- "version": "1.11.0",
3
+ "version": "1.13.0",
4
4
  "description": "Open-source voice toolkit. Speech-to-text (25 langs, ~19x faster than Whisper on Apple Silicon, ONNX fallback on CPU), text-to-speech (Kokoro + Vosk-TTS + ~180 macOS system voices, SSML), voice-activity detection, language detection (107 langs). Rust engine, OpenClaw skill.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -30,7 +30,7 @@
30
30
  ]
31
31
  },
32
32
  "keshaEngine": {
33
- "version": "1.11.0"
33
+ "version": "1.13.0"
34
34
  },
35
35
  "scripts": {
36
36
  "test": "bun test",
@@ -1,7 +1,7 @@
1
1
  import { defineCommand } from "citty";
2
2
  import { downloadEngine } from "../engine-install";
3
3
  import { getEngineBinPath } from "../engine";
4
- import { readStarSeen, shouldShowStarPrompt, writeStarSeen } from "../star";
4
+ import { maybeAskForStar } from "../star";
5
5
  import { log } from "../log";
6
6
 
7
7
  interface InstallCommandArgs {
@@ -10,6 +10,7 @@ interface InstallCommandArgs {
10
10
  "no-cache": boolean;
11
11
  tts: boolean;
12
12
  vad: boolean;
13
+ diarize: boolean;
13
14
  }
14
15
 
15
16
  const pkg = await Bun.file(new URL("../../package.json", import.meta.url)).json();
@@ -24,44 +25,24 @@ function resolveBackendFlag(coreml: boolean, onnx: boolean): string | undefined
24
25
  return undefined;
25
26
  }
26
27
 
27
- async function askForStar() {
28
- // Gate on major-or-minor bump only — patch releases and re-installs of the
29
- // same version shouldn't nag. First-ever install (no marker) still prompts.
30
- const currentVersion = typeof pkg.version === "string" ? pkg.version : null;
31
- if (!currentVersion) return;
32
- const binPath = getEngineBinPath();
33
- const seen = readStarSeen(binPath);
34
- if (!shouldShowStarPrompt(currentVersion, seen)) {
35
- return;
36
- }
37
- // Record the version up front so a single run never prompts twice, even
38
- // if the gh subprocess below throws.
39
- try {
40
- writeStarSeen(binPath, currentVersion);
41
- } catch {
42
- // Non-fatal — falling through to the prompt is still OK, just means we
43
- // may nag again on the next install if the write failed for IO reasons.
44
- }
45
-
46
- const gh = Bun.which("gh");
47
- if (!gh) {
48
- log.info("\nIf you enjoy Kesha Voice Kit, consider starring the repo:");
49
- log.info(" https://github.com/drakulavich/kesha-voice-kit");
50
- return;
28
+ async function performInstall(
29
+ noCache: boolean,
30
+ backend?: string,
31
+ tts = false,
32
+ vad = false,
33
+ diarize = false,
34
+ ) {
35
+ if (diarize && !(process.platform === "darwin" && process.arch === "arm64")) {
36
+ log.error(
37
+ "--diarize is currently darwin-arm64 only " +
38
+ "(see https://github.com/drakulavich/kesha-voice-kit/issues/199).",
39
+ );
40
+ process.exit(1);
51
41
  }
52
- const authCheck = Bun.spawnSync([gh, "auth", "status"], { stdout: "ignore", stderr: "ignore" });
53
- if (authCheck.exitCode !== 0) return;
54
- const starred = Bun.spawnSync([gh, "api", "user/starred/drakulavich/kesha-voice-kit"], { stdout: "ignore", stderr: "ignore" });
55
- if (starred.exitCode === 0) return; // already starred
56
- log.info("\n⭐ If you enjoy Kesha Voice Kit, star it on GitHub:");
57
- log.info(" https://github.com/drakulavich/kesha-voice-kit");
58
- log.info(' Or run: gh api -X PUT /user/starred/drakulavich/kesha-voice-kit');
59
- }
60
-
61
- async function performInstall(noCache: boolean, backend?: string, tts = false, vad = false) {
62
42
  try {
63
- await downloadEngine(noCache, backend, { tts, vad });
64
- await askForStar();
43
+ await downloadEngine(noCache, backend, { tts, vad, diarize });
44
+ const currentVersion = typeof pkg.version === "string" ? pkg.version : null;
45
+ await maybeAskForStar(getEngineBinPath(), currentVersion, log);
65
46
  } catch (err: unknown) {
66
47
  const message = err instanceof Error ? err.message : String(err);
67
48
  log.error(message);
@@ -100,9 +81,14 @@ export const installCommand = defineCommand({
100
81
  description: "Also install Silero VAD (~2.3MB) for long-audio preprocessing",
101
82
  default: false,
102
83
  },
84
+ diarize: {
85
+ type: "boolean",
86
+ description: "Also install the Sortformer streaming-diarization model (~245MB, darwin-arm64 only — #199)",
87
+ default: false,
88
+ },
103
89
  },
104
90
  async run({ args }: { args: InstallCommandArgs }) {
105
91
  const backend = resolveBackendFlag(args.coreml, args.onnx);
106
- await performInstall(args["no-cache"], backend, args.tts, args.vad);
92
+ await performInstall(args["no-cache"], backend, args.tts, args.vad, args.diarize);
107
93
  },
108
94
  });
package/src/cli/main.ts CHANGED
@@ -24,6 +24,7 @@ interface MainCommandArgs {
24
24
  vad: boolean;
25
25
  "no-vad": boolean;
26
26
  timestamps: boolean;
27
+ speakers: boolean;
27
28
  format?: string;
28
29
  lang?: string;
29
30
  }
@@ -63,6 +64,11 @@ export const mainCommand = defineCommand({
63
64
  description: "Include timestamped transcript segments in JSON/TOON output",
64
65
  default: false,
65
66
  },
67
+ speakers: {
68
+ type: "boolean",
69
+ description: "Include speaker labels in transcript segments. Requires --json / --toon / --format json. Implies --timestamps. Currently darwin-arm64 only (#199).",
70
+ default: false,
71
+ },
66
72
  verbose: {
67
73
  type: "boolean",
68
74
  description: "Show language detection details",
@@ -109,6 +115,10 @@ export const mainCommand = defineCommand({
109
115
  log.error("--timestamps requires --json, --toon, or --format json.");
110
116
  process.exit(2);
111
117
  }
118
+ if (args.speakers && !(args.json || args.toon || args.format === "json")) {
119
+ log.error("--speakers requires --json, --toon, or --format json.");
120
+ process.exit(2);
121
+ }
112
122
  const vadMode = args.vad ? "on" : args["no-vad"] ? "off" : "auto";
113
123
 
114
124
  if (files.length === 0) {
@@ -127,7 +137,7 @@ export const mainCommand = defineCommand({
127
137
  // Run audio lang-id and transcription concurrently.
128
138
  const [audioResult, transcript] = await Promise.all([
129
139
  wantsLangId ? detectAudioLanguageEngine(file) : Promise.resolve(null),
130
- transcribeWithSegments(file, { vad: vadMode, timestamps: args.timestamps }),
140
+ transcribeWithSegments(file, { vad: vadMode, timestamps: args.timestamps, speakers: args.speakers }),
131
141
  ]);
132
142
  const { text, segments } = transcript;
133
143
 
@@ -35,36 +35,72 @@ function getEngineBinaryName(): string {
35
35
  }
36
36
 
37
37
  /**
38
- * Fetch the AVSpeechSynthesizer sidecar (#141) and place it next to the
39
- * engine binary on darwin-arm64. The Rust side (`avspeech::helper_path`)
40
- * looks for a `say-avspeech` file adjacent to the running executable, so
41
- * the filename on disk is always `say-avspeech` regardless of the release
42
- * asset name.
43
- *
44
- * Best-effort: 404s (older engine versions predate the sidecar) and
45
- * network errors log a warning and return — macos-* voices simply won't
46
- * be available, which is a graceful degradation. The user keeps Kokoro +
47
- * Vosk-TTS.
38
+ * One sidecar's identity. Each shipped Swift sidecar is described by an
39
+ * entry in `SIDECARS`; the helper below loops over them. Centralising the
40
+ * spec keeps the AVSpeech (#141) and diarize (#199) install paths in
41
+ * lockstep — adding a third sidecar is one entry, not a new function.
48
42
  */
49
- async function downloadAVSpeechSidecar(binPath: string, engineVersion: string): Promise<void> {
43
+ interface SidecarSpec {
44
+ /** Filename written next to the engine binary. The Rust runtime probes
45
+ * this exact name (sometimes a list — see diarize::sidecar_path). */
46
+ fileBasename: string;
47
+ /** Release asset name. Often equals fileBasename, but AVSpeech writes
48
+ * `say-avspeech` while the asset is `say-avspeech-darwin-arm64`. */
49
+ assetName: string;
50
+ /** Human-readable name in log messages. */
51
+ displayName: string;
52
+ /** Trailing hint on success: "AVSpeech sidecar installed (<hint>)." */
53
+ availableHint: string;
54
+ /** Trailing hint on any failure path: "...; <hint>." */
55
+ unavailableHint: string;
56
+ }
57
+
58
+ const SIDECARS: SidecarSpec[] = [
59
+ {
60
+ fileBasename: "say-avspeech",
61
+ assetName: "say-avspeech-darwin-arm64",
62
+ displayName: "AVSpeech sidecar",
63
+ availableHint: "macOS voices available",
64
+ unavailableHint: "macos-* voices unavailable",
65
+ },
66
+ {
67
+ fileBasename: "kesha-diarize-darwin-arm64",
68
+ assetName: "kesha-diarize-darwin-arm64",
69
+ displayName: "Diarization sidecar",
70
+ availableHint: "--speakers available",
71
+ unavailableHint: "--speakers unavailable",
72
+ },
73
+ ];
74
+
75
+ /**
76
+ * Fetch a single Swift sidecar and place it next to the engine binary on
77
+ * darwin-arm64. Best-effort: 404s (older engine versions predate this
78
+ * sidecar) and network errors log a warning and return — the corresponding
79
+ * feature simply won't be available. The user keeps everything else.
80
+ */
81
+ async function downloadSidecar(
82
+ spec: SidecarSpec,
83
+ binPath: string,
84
+ engineVersion: string,
85
+ ): Promise<void> {
50
86
  if (process.platform !== "darwin" || process.arch !== "arm64") return;
51
87
 
52
- const sidecarPath = join(dirname(binPath), "say-avspeech");
53
- const url = `https://github.com/${GITHUB_REPO}/releases/download/v${engineVersion}/say-avspeech-darwin-arm64`;
88
+ const sidecarPath = join(dirname(binPath), spec.fileBasename);
89
+ const url = `https://github.com/${GITHUB_REPO}/releases/download/v${engineVersion}/${spec.assetName}`;
54
90
 
55
91
  let res: Response;
56
92
  try {
57
93
  res = await fetch(url, { redirect: "follow" });
58
94
  } catch (e) {
59
95
  log.warn(
60
- `Could not fetch AVSpeech sidecar (${e instanceof Error ? e.message : e}); macos-* voices unavailable.`,
96
+ `Could not fetch ${spec.displayName} (${e instanceof Error ? e.message : e}); ${spec.unavailableHint}.`,
61
97
  );
62
98
  return;
63
99
  }
64
100
 
65
101
  if (!res.ok) {
66
102
  log.warn(
67
- `AVSpeech sidecar not in release v${engineVersion} (HTTP ${res.status}); macos-* voices unavailable.`,
103
+ `${spec.displayName} not in release v${engineVersion} (HTTP ${res.status}); ${spec.unavailableHint}.`,
68
104
  );
69
105
  return;
70
106
  }
@@ -72,17 +108,17 @@ async function downloadAVSpeechSidecar(binPath: string, engineVersion: string):
72
108
  // Keep the best-effort contract: streamResponseToFile throws on an empty
73
109
  // body and can fail mid-stream, and chmodSync can throw EPERM. Without
74
110
  // this catch a stream/chmod failure would propagate through the tail
75
- // `await sidecarPromise` in downloadEngine — converting a successful
76
- // engine install into a thrown exception after log.success already
77
- // announced it, which is exactly the regression the fetch/404 branches
78
- // above protect against.
111
+ // `await Promise.all(sidecarPromises)` in downloadEngine — converting a
112
+ // successful engine install into a thrown exception after log.success
113
+ // already announced it, which is exactly the regression the fetch/404
114
+ // branches above protect against.
79
115
  try {
80
- await streamResponseToFile(res, sidecarPath, "say-avspeech sidecar");
116
+ await streamResponseToFile(res, sidecarPath, spec.displayName);
81
117
  chmodSync(sidecarPath, 0o755);
82
- log.success("AVSpeech sidecar installed (macOS voices available).");
118
+ log.success(`${spec.displayName} installed (${spec.availableHint}).`);
83
119
  } catch (e) {
84
120
  log.warn(
85
- `AVSpeech sidecar install failed (${e instanceof Error ? e.message : e}); macos-* voices unavailable.`,
121
+ `${spec.displayName} install failed (${e instanceof Error ? e.message : e}); ${spec.unavailableHint}.`,
86
122
  );
87
123
  }
88
124
  }
@@ -92,6 +128,9 @@ export interface InstallOptions {
92
128
  tts?: boolean;
93
129
  /** Also install Silero VAD model for long-audio preprocessing. */
94
130
  vad?: boolean;
131
+ /** Also install the Sortformer streaming-diarization model (~245MB,
132
+ * darwin-arm64 only — see #199). */
133
+ diarize?: boolean;
95
134
  }
96
135
 
97
136
  export async function downloadEngine(
@@ -117,13 +156,14 @@ export async function downloadEngine(
117
156
 
118
157
  if (cacheValid) {
119
158
  log.success(`Engine binary already installed (v${engineVersion}).`);
120
- // Cover the upgrade path from pre-#141 engines that never had a
121
- // sidecar: if the cached engine is current but the sibling sidecar
122
- // is missing, fetch it now so macos-* voices start working.
123
- const sidecarPath = join(dirname(binPath), "say-avspeech");
124
- if (!existsSync(sidecarPath)) {
125
- await downloadAVSpeechSidecar(binPath, engineVersion);
126
- }
159
+ // Top up any sidecars missing from this cached install. Pre-#141 / pre-#199
160
+ // engines never shipped them, so a cache-valid binary may still need
161
+ // fetching. Run independent fetches concurrently — same shape as the
162
+ // cold path below.
163
+ const missing = SIDECARS.filter(
164
+ (s) => !existsSync(join(dirname(binPath), s.fileBasename)),
165
+ );
166
+ await Promise.all(missing.map((s) => downloadSidecar(s, binPath, engineVersion)));
127
167
  } else {
128
168
  // Log why we're downloading — helps diagnose surprising re-downloads.
129
169
  if (existsSync(binPath) && installedVersion && installedVersion !== engineVersion) {
@@ -136,32 +176,34 @@ export async function downloadEngine(
136
176
 
137
177
  mkdirSync(dirname(binPath), { recursive: true });
138
178
 
139
- // Kick off the sidecar fetch concurrently with the engine fetch. Both
140
- // target github.com release assets on independent paths with no data
141
- // dependency, so overlapping the HTTP round-trips saves ~15-30s on a
142
- // cold install. Sidecar is best-effort (404 on older engines, warn +
143
- // continue) so a failure doesn't cascade into the engine path.
144
- const sidecarPromise = downloadAVSpeechSidecar(binPath, engineVersion);
179
+ // Kick off all sidecar fetches concurrently with the engine fetch. They
180
+ // target independent github.com release assets, so overlapping the HTTP
181
+ // round-trips saves ~15-30s on a cold install. Each sidecar is
182
+ // best-effort (404 on older engines, warn + continue) so a failure
183
+ // doesn't cascade into the engine path.
184
+ const sidecarPromises = SIDECARS.map((s) =>
185
+ downloadSidecar(s, binPath, engineVersion),
186
+ );
187
+ // Defense-in-depth: if the engine fetch throws below, attach no-op
188
+ // rejection handlers so we don't surface unhandledRejection errors
189
+ // from sidecar paths whose internal try/catch ever drifts. Logs from
190
+ // sidecars whose own work is still in flight will print when they
191
+ // complete; the engine error is what the user needs to see now.
192
+ const muteSidecarRejections = () =>
193
+ sidecarPromises.forEach((p) => p.catch(() => {}));
145
194
 
146
195
  let res: Response;
147
196
  try {
148
197
  res = await fetch(url, { redirect: "follow" });
149
198
  } catch (e) {
150
- // Attach a no-op rejection handler to the sidecar promise as
151
- // defense-in-depth. Today it can't reject (downloadAVSpeechSidecar
152
- // catches all of its own errors), but if that contract ever drifts
153
- // a bare dangling promise would surface as an unhandledRejection
154
- // while we're throwing the engine error. Not waiting — the engine
155
- // error is what the user needs to see now; sidecar's own logs
156
- // will print whenever they finish.
157
- sidecarPromise.catch(() => {});
199
+ muteSidecarRejections();
158
200
  throw new Error(
159
201
  `Failed to fetch engine binary: ${e instanceof Error ? e.message : e}\n Fix: Check your network connection and try again`,
160
202
  );
161
203
  }
162
204
 
163
205
  if (!res.ok) {
164
- sidecarPromise.catch(() => {});
206
+ muteSidecarRejections();
165
207
  throw new Error(
166
208
  `Failed to download engine binary (HTTP ${res.status})\n Fix: Check https://github.com/${GITHUB_REPO}/releases for available versions`,
167
209
  );
@@ -171,7 +213,7 @@ export async function downloadEngine(
171
213
  chmodSync(binPath, 0o755);
172
214
  writeInstalledEngineVersion(binPath, engineVersion);
173
215
  log.success(`Engine binary downloaded (v${engineVersion}).`);
174
- await sidecarPromise;
216
+ await Promise.all(sidecarPromises);
175
217
  }
176
218
 
177
219
  if (backend) {
@@ -189,6 +231,7 @@ export async function downloadEngine(
189
231
  ...(noCache ? ["--no-cache"] : []),
190
232
  ...(options.tts ? ["--tts"] : []),
191
233
  ...(options.vad ? ["--vad"] : []),
234
+ ...(options.diarize ? ["--diarize"] : []),
192
235
  ];
193
236
  const proc = Bun.spawnSync([binPath, ...installArgs], {
194
237
  stdout: "pipe",
package/src/engine.ts CHANGED
@@ -6,10 +6,17 @@ import { log } from "./log";
6
6
  /**
7
7
  * Capability-flag string surfaced via `kesha-engine --capabilities-json`. Single
8
8
  * source of truth so the engine, the TS CLI gate, and the integration tests
9
- * can't drift. Mirrors `rust/src/transcribe.rs::TRANSCRIBE_SEGMENTS_FEATURE`.
9
+ * can't drift. Mirrors `rust/src/transcribe/mod.rs::TRANSCRIBE_SEGMENTS_FEATURE`.
10
10
  */
11
11
  export const TRANSCRIBE_SEGMENTS_FEATURE = "transcribe.segments";
12
12
 
13
+ /**
14
+ * Capability-flag string for speaker diarization. Engine advertises this only
15
+ * on darwin-arm64 builds with the `system_diarize` cargo feature (#199).
16
+ * Mirrors `rust/src/transcribe/mod.rs::TRANSCRIBE_DIARIZE_FEATURE`.
17
+ */
18
+ export const TRANSCRIBE_DIARIZE_FEATURE = "transcribe.diarize";
19
+
13
20
  export interface LangDetectResult {
14
21
  code: string;
15
22
  confidence: number;
@@ -19,6 +26,8 @@ export interface TranscriptionSegment {
19
26
  start: number;
20
27
  end: number;
21
28
  text: string;
29
+ /** Speaker cluster id when `--speakers` was requested (#199). */
30
+ speaker?: number;
22
31
  }
23
32
 
24
33
  export interface TranscriptionOutput {
@@ -69,6 +78,9 @@ export type VadMode = "auto" | "on" | "off";
69
78
 
70
79
  export interface TranscribeEngineOptions {
71
80
  vad?: VadMode;
81
+ /** Request speaker labels in transcript segments. Requires the engine to
82
+ * advertise `transcribe.diarize` (darwin-arm64 only — see #199). */
83
+ speakers?: boolean;
72
84
  }
73
85
 
74
86
  export async function transcribeEngine(
@@ -100,11 +112,9 @@ function parseTranscriptionOutput(stdout: string): TranscriptionOutput {
100
112
  ) {
101
113
  throw new Error("Invalid transcription segment returned by kesha-engine");
102
114
  }
103
- return {
104
- start: s.start,
105
- end: s.end,
106
- text: s.text,
107
- };
115
+ const out: TranscriptionSegment = { start: s.start, end: s.end, text: s.text };
116
+ if (typeof s.speaker === "number") out.speaker = s.speaker;
117
+ return out;
108
118
  });
109
119
 
110
120
  return { text: parsed.text, segments };
@@ -124,6 +134,15 @@ export async function transcribeEngineWithSegments(
124
134
  const args = ["transcribe", audioPath, "--json"];
125
135
  if (opts.vad === "on") args.push("--vad");
126
136
  else if (opts.vad === "off") args.push("--no-vad");
137
+ if (opts.speakers) {
138
+ if (!caps.features.includes(TRANSCRIBE_DIARIZE_FEATURE)) {
139
+ throw new Error(
140
+ "speaker diarization is currently darwin-arm64 only " +
141
+ "(see https://github.com/drakulavich/kesha-voice-kit/issues/199)",
142
+ );
143
+ }
144
+ args.push("--speakers");
145
+ }
127
146
  const { stdout, stderr, exitCode } = await runEngine(args);
128
147
  if (exitCode !== 0) {
129
148
  throw new Error(stderr || `kesha-engine exited with code ${exitCode}`);
package/src/star.ts CHANGED
@@ -55,3 +55,79 @@ export function shouldShowStarPrompt(current: string, seen: string | null): bool
55
55
  export function hasStarMarker(binPath: string): boolean {
56
56
  return existsSync(starSeenPath(binPath));
57
57
  }
58
+
59
+ /**
60
+ * Prompt the user to star the repo if and only if `shouldShowStarPrompt`
61
+ * agrees (first install + major-or-minor bumps, never on patch). Records
62
+ * the prompt against the current version up front so a single run never
63
+ * prompts twice — failures from the gh subprocess below don't reopen the
64
+ * gate. Shared by `kesha install` and `kesha status` so opt-in installs
65
+ * (`--tts`, `--diarize`) and `status` reuse the same marker; a user who
66
+ * saw the prompt on the base install won't see it again on the opt-in or
67
+ * the status check.
68
+ *
69
+ * No-ops when the gate says skip, when `currentVersion` is null, or when
70
+ * the user has already starred. When `gh` is missing or unauthenticated,
71
+ * a basic prompt is still printed (so the marker write isn't a silent
72
+ * slot consumption).
73
+ *
74
+ * `shims` lets tests inject deterministic `which` / `spawn` implementations.
75
+ * Production callers leave it undefined; the defaults route through Bun's
76
+ * built-ins. Bun.which() caches PATH at process start so swapping
77
+ * process.env.PATH in tests doesn't work — the shim is the supported way
78
+ * to fake gh's presence and authentication state from a unit test.
79
+ */
80
+ export async function maybeAskForStar(
81
+ binPath: string,
82
+ currentVersion: string | null,
83
+ log: { info: (msg: string) => void },
84
+ shims?: {
85
+ which?: (name: string) => string | null;
86
+ spawn?: (cmd: string[]) => { exitCode: number | null };
87
+ },
88
+ ): Promise<void> {
89
+ const which = shims?.which ?? ((n: string) => Bun.which(n));
90
+ const spawn =
91
+ shims?.spawn ??
92
+ ((cmd: string[]) =>
93
+ Bun.spawnSync(cmd, { stdout: "ignore", stderr: "ignore" }));
94
+ if (!currentVersion) return;
95
+ const seen = readStarSeen(binPath);
96
+ if (!shouldShowStarPrompt(currentVersion, seen)) {
97
+ return;
98
+ }
99
+ try {
100
+ writeStarSeen(binPath, currentVersion);
101
+ } catch {
102
+ /* Non-fatal — falling through to the prompt is still OK. */
103
+ }
104
+
105
+ // The marker has been recorded — every path from here that reaches
106
+ // a `return` without printing would silently consume the user's
107
+ // once-per-major.minor slot. Only the "already starred" path is
108
+ // allowed to skip the print, since the consumption is the verification
109
+ // that they're already supporting the project.
110
+ const printBasicPrompt = () => {
111
+ log.info("\nIf you enjoy Kesha Voice Kit, consider starring the repo:");
112
+ log.info(" https://github.com/drakulavich/kesha-voice-kit");
113
+ };
114
+
115
+ const gh = which("gh");
116
+ if (!gh) {
117
+ printBasicPrompt();
118
+ return;
119
+ }
120
+ const authCheck = spawn([gh, "auth", "status"]);
121
+ if (authCheck.exitCode !== 0) {
122
+ // gh installed but unauthenticated — we can't check star status, but
123
+ // we can still nudge with the same basic prompt the no-gh path uses.
124
+ // Without this, the marker write above silently consumes the slot.
125
+ printBasicPrompt();
126
+ return;
127
+ }
128
+ const starred = spawn([gh, "api", "user/starred/drakulavich/kesha-voice-kit"]);
129
+ if (starred.exitCode === 0) return; // already starred — slot consumed by verification
130
+ log.info("\n⭐ If you enjoy Kesha Voice Kit, star it on GitHub:");
131
+ log.info(" https://github.com/drakulavich/kesha-voice-kit");
132
+ log.info(' Or run: gh api -X PUT /user/starred/drakulavich/kesha-voice-kit');
133
+ }
package/src/status.ts CHANGED
@@ -3,8 +3,11 @@ import { homedir } from "os";
3
3
  import { join } from "path";
4
4
  import { isEngineInstalled, getEngineBinPath, getEngineCapabilities } from "./engine";
5
5
  import { log } from "./log";
6
+ import { maybeAskForStar } from "./star";
6
7
  import pc from "picocolors";
7
8
 
9
+ const pkg = await Bun.file(new URL("../package.json", import.meta.url)).json();
10
+
8
11
  function humanBytes(bytes: number): string {
9
12
  if (bytes < 1024) return `${bytes} B`;
10
13
  const units = ["KB", "MB", "GB"];
@@ -86,7 +89,16 @@ export async function showStatus(): Promise<void> {
86
89
 
87
90
  if (!installed) {
88
91
  log.warn('Run "kesha install" to download the engine and models.');
92
+ return;
89
93
  }
94
+
95
+ // Star prompt at the very end. Same gate + marker as `kesha install`, so a
96
+ // user prompted by either path won't be re-prompted by the other within the
97
+ // same major.minor. Headless `kesha install` (no `gh`, or no tty) leaves no
98
+ // marker only when the prompt path itself errored out — readStarSeen still
99
+ // sees the marker in the common case.
100
+ const currentVersion = typeof pkg.version === "string" ? pkg.version : null;
101
+ await maybeAskForStar(binPath, currentVersion, log);
90
102
  }
91
103
 
92
104
  function showDiskUsage(binPath: string): void {
package/src/transcribe.ts CHANGED
@@ -15,6 +15,10 @@ export interface TranscribeOptions {
15
15
  vad?: VadMode;
16
16
  /** Request timestamped transcript segments from the engine. */
17
17
  timestamps?: boolean;
18
+ /** Request speaker labels in transcript segments (#199). Implies `timestamps`.
19
+ * Currently darwin-arm64 only — throws when the engine doesn't advertise
20
+ * `transcribe.diarize`. */
21
+ speakers?: boolean;
18
22
  }
19
23
 
20
24
  export async function transcribe(audioPath: string, opts: TranscribeOptions = {}): Promise<string> {
@@ -36,8 +40,11 @@ export async function transcribeWithSegments(
36
40
  );
37
41
  }
38
42
 
39
- if (opts.timestamps) {
40
- return transcribeEngineWithSegments(audioPath, { vad: opts.vad });
43
+ if (opts.timestamps || opts.speakers) {
44
+ return transcribeEngineWithSegments(audioPath, {
45
+ vad: opts.vad,
46
+ speakers: opts.speakers,
47
+ });
41
48
  }
42
49
 
43
50
  const text = await transcribeEngine(audioPath, { vad: opts.vad });