@drakulavich/kesha-voice-kit 1.2.1 → 1.3.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
@@ -83,7 +83,9 @@ Stdout: transcript. Stderr: errors. Pipe-friendly. Also available as `parakeet`
83
83
  Kesha speaks back via Kokoro-82M (English) and Piper (Russian). Voice is auto-picked from the input text's language — `en` routes to Kokoro, `ru` to Piper. Pass `--voice` to override.
84
84
 
85
85
  ```bash
86
- brew install espeak-ng # one-time system dep (macOS — apt/choco elsewhere)
86
+ brew install espeak-ng # one-time system dep — macOS
87
+ # Linux: sudo apt install espeak-ng
88
+ # Windows: choco install espeak-ng (puts libespeak-ng.dll on PATH)
87
89
  kesha install --tts # ~390MB (Kokoro + Piper RU, opt-in)
88
90
  kesha say "Hello, world" > hello.wav
89
91
  kesha say "Привет, мир" > privet.wav # auto-routes to ru-denis
@@ -98,16 +100,16 @@ Output format: WAV mono float32 (24 kHz for Kokoro, 22.05 kHz for Piper). OGG/Op
98
100
  **Supported voices:**
99
101
  - English: `en-af_heart` (default), plus any Kokoro voice you download into `~/.cache/kesha/models/kokoro-82m/voices/`
100
102
  - Russian: `ru-denis` (default). More speakers (dmitri, irina, ruslan) are ready to drop in once needed.
101
- - macOS system voices (preview — dev builds only, see below): `macos-<identifier-or-language>` routes to `AVSpeechSynthesizer`. Zero install, any of the 180+ voices already on your Mac.
103
+ - macOS system voices: `macos-<identifier-or-language>` routes to `AVSpeechSynthesizer`. Zero install, any of the 180+ voices already on your Mac.
102
104
 
103
- ### macOS system voices (preview)
105
+ ### macOS system voices
104
106
 
105
- `kesha say --voice macos-*` routes through `AVSpeechSynthesizer` on macOS, so you get voice synthesis for free — no 390 MB TTS bundle, no `espeak-ng` dep. Currently gated behind the `system_tts` cargo feature; release-binary integration is tracked as part of [#141](https://github.com/drakulavich/kesha-voice-kit/issues/141).
107
+ `kesha say --voice macos-*` routes through `AVSpeechSynthesizer` on macOS, so you get voice synthesis for free — no 390 MB TTS bundle, no `espeak-ng` dep. The sidecar binary ships alongside `kesha-engine` on darwin-arm64 releases (#141); `kesha install` places both in `~/.cache/kesha/bin/`.
106
108
 
107
109
  ```bash
108
- # From a dev build: `cargo build --release --features system_tts` in rust/.
109
- kesha say --voice macos-com.apple.voice.compact.en-US.Samantha "Hello from system TTS" > out.wav
110
- kesha say --voice macos-ru-RU "Привет, мир" > hello-ru.wav # language-code fallback
110
+ kesha say --list-voices | grep ^macos- # discover installed voices
111
+ kesha say --voice macos-com.apple.voice.compact.en-US.Samantha "Hello" > out.wav
112
+ kesha say --voice macos-ru-RU "Привет, мир" > hello-ru.wav # language-code fallback
111
113
  ```
112
114
 
113
115
  Voice id format: `macos-<id>` where `<id>` is either a full Apple identifier (`com.apple.voice.compact.en-US.Samantha`) or a language code (`en-US`, `ru-RU`) — the Swift helper tries the identifier first and falls back to the language. Output is mono float32 @ 22050 Hz, structurally identical to Piper.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakulavich/kesha-voice-kit",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
4
4
  "description": "Open-source voice toolkit for Apple Silicon. Speech-to-text, language detection. 25 languages.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -29,7 +29,7 @@
29
29
  ]
30
30
  },
31
31
  "keshaEngine": {
32
- "version": "1.2.0"
32
+ "version": "1.3.0"
33
33
  },
34
34
  "scripts": {
35
35
  "test": "bun test",
package/src/cli.ts CHANGED
@@ -4,7 +4,8 @@ import { defineCommand, runMain } from "citty";
4
4
  import { detect } from "tinyld";
5
5
  import { transcribe } from "./lib";
6
6
  import { downloadEngine } from "./engine-install";
7
- import { detectAudioLanguageEngine, detectTextLanguageEngine } from "./engine";
7
+ import { detectAudioLanguageEngine, detectTextLanguageEngine, getEngineBinPath } from "./engine";
8
+ import { readStarSeen, shouldShowStarPrompt, writeStarSeen } from "./star";
8
9
  import type { LangDetectResult } from "./engine";
9
10
  import { log } from "./log";
10
11
  import { say, SayError } from "./say";
@@ -50,6 +51,24 @@ function resolveBackendFlag(coreml: boolean, onnx: boolean): string | undefined
50
51
  }
51
52
 
52
53
  async function askForStar() {
54
+ // Gate on major-or-minor bump only — patch releases and re-installs of the
55
+ // same version shouldn't nag. First-ever install (no marker) still prompts.
56
+ const currentVersion = typeof pkg.version === "string" ? pkg.version : null;
57
+ if (!currentVersion) return;
58
+ const binPath = getEngineBinPath();
59
+ const seen = readStarSeen(binPath);
60
+ if (!shouldShowStarPrompt(currentVersion, seen)) {
61
+ return;
62
+ }
63
+ // Record the version up front so a single run never prompts twice, even
64
+ // if the gh subprocess below throws.
65
+ try {
66
+ writeStarSeen(binPath, currentVersion);
67
+ } catch {
68
+ // Non-fatal — falling through to the prompt is still OK, just means we
69
+ // may nag again on the next install if the write failed for IO reasons.
70
+ }
71
+
53
72
  const gh = Bun.which("gh");
54
73
  if (!gh) {
55
74
  log.info("\nIf you enjoy Kesha Voice Kit, consider starring the repo:");
@@ -1,4 +1,4 @@
1
- import { dirname } from "path";
1
+ import { dirname, join } from "path";
2
2
  import { existsSync, mkdirSync, chmodSync, readFileSync, writeFileSync } from "fs";
3
3
  import { getEngineBinPath, getEngineCapabilities } from "./engine";
4
4
  import { log } from "./log";
@@ -41,6 +41,46 @@ function getEngineBinaryName(): string {
41
41
  throw new Error(`Unsupported platform: ${platform} ${arch}`);
42
42
  }
43
43
 
44
+ /**
45
+ * Fetch the AVSpeechSynthesizer sidecar (#141) and place it next to the
46
+ * engine binary on darwin-arm64. The Rust side (`avspeech::helper_path`)
47
+ * looks for a `say-avspeech` file adjacent to the running executable, so
48
+ * the filename on disk is always `say-avspeech` regardless of the release
49
+ * asset name.
50
+ *
51
+ * Best-effort: 404s (older engine versions predate the sidecar) and
52
+ * network errors log a warning and return — macos-* voices simply won't
53
+ * be available, which is a graceful degradation. The user keeps Kokoro +
54
+ * Piper.
55
+ */
56
+ async function downloadAVSpeechSidecar(binPath: string, engineVersion: string): Promise<void> {
57
+ if (process.platform !== "darwin" || process.arch !== "arm64") return;
58
+
59
+ const sidecarPath = join(dirname(binPath), "say-avspeech");
60
+ const url = `https://github.com/${GITHUB_REPO}/releases/download/v${engineVersion}/say-avspeech-darwin-arm64`;
61
+
62
+ let res: Response;
63
+ try {
64
+ res = await fetch(url, { redirect: "follow" });
65
+ } catch (e) {
66
+ log.warn(
67
+ `Could not fetch AVSpeech sidecar (${e instanceof Error ? e.message : e}); macos-* voices unavailable.`,
68
+ );
69
+ return;
70
+ }
71
+
72
+ if (!res.ok) {
73
+ log.warn(
74
+ `AVSpeech sidecar not in release v${engineVersion} (HTTP ${res.status}); macos-* voices unavailable.`,
75
+ );
76
+ return;
77
+ }
78
+
79
+ await streamResponseToFile(res, sidecarPath, "say-avspeech sidecar");
80
+ chmodSync(sidecarPath, 0o755);
81
+ log.success("AVSpeech sidecar installed (macOS voices available).");
82
+ }
83
+
44
84
  export interface InstallOptions {
45
85
  /** Also install Kokoro TTS models. Requires espeak-ng on PATH. */
46
86
  tts?: boolean;
@@ -69,6 +109,13 @@ export async function downloadEngine(
69
109
 
70
110
  if (cacheValid) {
71
111
  log.success(`Engine binary already installed (v${engineVersion}).`);
112
+ // Cover the upgrade path from pre-#141 engines that never had a
113
+ // sidecar: if the cached engine is current but the sibling sidecar
114
+ // is missing, fetch it now so macos-* voices start working.
115
+ const sidecarPath = join(dirname(binPath), "say-avspeech");
116
+ if (!existsSync(sidecarPath)) {
117
+ await downloadAVSpeechSidecar(binPath, engineVersion);
118
+ }
72
119
  } else {
73
120
  // Log why we're downloading — helps diagnose surprising re-downloads.
74
121
  if (existsSync(binPath) && installedVersion && installedVersion !== engineVersion) {
@@ -100,6 +147,7 @@ export async function downloadEngine(
100
147
  chmodSync(binPath, 0o755);
101
148
  writeInstalledEngineVersion(binPath, engineVersion);
102
149
  log.success(`Engine binary downloaded (v${engineVersion}).`);
150
+ await downloadAVSpeechSidecar(binPath, engineVersion);
103
151
  }
104
152
 
105
153
  if (backend) {
package/src/star.ts ADDED
@@ -0,0 +1,57 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "fs";
2
+
3
+ /**
4
+ * Version-bump gate for the "star the repo" prompt in `kesha install`.
5
+ *
6
+ * Prompts are valuable on first install and on meaningful upgrades (new
7
+ * features) but annoying on patch-only bumps. This module persists the
8
+ * last-seen version to a marker file next to the engine binary and only
9
+ * returns true on a major-or-minor bump.
10
+ */
11
+ export function starSeenPath(binPath: string): string {
12
+ return `${binPath}.star-seen`;
13
+ }
14
+
15
+ export function readStarSeen(binPath: string): string | null {
16
+ try {
17
+ const v = readFileSync(starSeenPath(binPath), "utf-8").trim();
18
+ return v.length > 0 ? v : null;
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+
24
+ export function writeStarSeen(binPath: string, version: string): void {
25
+ writeFileSync(starSeenPath(binPath), `${version}\n`);
26
+ }
27
+
28
+ function parseMajorMinor(v: string): [number, number] | null {
29
+ const parts = v.split(".");
30
+ if (parts.length < 2) return null;
31
+ const major = Number(parts[0]);
32
+ const minor = Number(parts[1]);
33
+ if (!Number.isFinite(major) || !Number.isFinite(minor)) return null;
34
+ return [major, minor];
35
+ }
36
+
37
+ /**
38
+ * Returns true iff `current` represents a major-or-minor bump over `seen`.
39
+ * - `seen === null` → true (first install, always prompt once).
40
+ * - Same or downgraded version → false.
41
+ * - Patch-only bump → false (annoying on every install).
42
+ * - Unparseable either side → false (don't nag when we can't reason).
43
+ */
44
+ export function shouldShowStarPrompt(current: string, seen: string | null): boolean {
45
+ if (seen === null) return true;
46
+ const c = parseMajorMinor(current);
47
+ const s = parseMajorMinor(seen);
48
+ if (!c || !s) return false;
49
+ if (c[0] > s[0]) return true;
50
+ if (c[0] === s[0] && c[1] > s[1]) return true;
51
+ return false;
52
+ }
53
+
54
+ /** True when a star-seen marker already exists for this install. */
55
+ export function hasStarMarker(binPath: string): boolean {
56
+ return existsSync(starSeenPath(binPath));
57
+ }