@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 +9 -7
- package/package.json +2 -2
- package/src/cli.ts +20 -1
- package/src/engine-install.ts +49 -1
- package/src/star.ts +57 -0
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
109
|
-
kesha say --voice macos-com.apple.voice.compact.en-US.Samantha "Hello
|
|
110
|
-
kesha say --voice macos-ru-RU "Привет, мир" > hello-ru.wav
|
|
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.
|
|
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.
|
|
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:");
|
package/src/engine-install.ts
CHANGED
|
@@ -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
|
+
}
|