@drakulavich/kesha-voice-kit 1.12.0 → 1.14.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 +53 -1
- package/SKILL.md +2 -0
- package/package.json +2 -2
- package/src/cli/say.ts +2 -25
- package/src/cli.ts +2 -1
- package/src/engine-install.ts +73 -9
- package/src/engine.ts +9 -0
- package/src/lib.ts +1 -1
- package/src/log.ts +10 -1
- package/src/{say.ts → synth.ts} +19 -4
- package/src/voice-routing.ts +23 -0
package/README.md
CHANGED
|
@@ -25,13 +25,53 @@ Runtime: **[Bun](https://bun.sh)** >= 1.3.0.
|
|
|
25
25
|
```bash
|
|
26
26
|
curl -fsSL https://bun.sh/install | bash # skip if Bun is already installed
|
|
27
27
|
|
|
28
|
-
bun
|
|
28
|
+
bun add -g @drakulavich/kesha-voice-kit
|
|
29
29
|
kesha install # downloads engine + models
|
|
30
30
|
kesha audio.ogg # transcript to stdout
|
|
31
31
|
```
|
|
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. Supported systems: `aarch64-darwin`, `x86_64-linux`.
|
|
38
|
+
|
|
39
|
+
### One-liner run (no install)
|
|
40
|
+
```bash
|
|
41
|
+
nix run github:drakulavich/kesha-voice-kit -- install # downloads models (engine is bundled)
|
|
42
|
+
nix run github:drakulavich/kesha-voice-kit -- audio.ogg # transcribe
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`nix run` resolves to `apps.default` (the `kesha` Bun CLI), which has the engine binary baked in via `KESHA_ENGINE_BIN`, so there's no separate engine download.
|
|
46
|
+
|
|
47
|
+
### Install to profile (persistent)
|
|
48
|
+
```bash
|
|
49
|
+
nix profile install github:drakulavich/kesha-voice-kit
|
|
50
|
+
kesha install # downloads models
|
|
51
|
+
kesha audio.ogg # transcript to stdout
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`packages.default` ships the Bun CLI (`kesha`, `parakeet`) wired to the Nix-built engine. After `nix profile install`, both shims are on `PATH` and run transcription, language detection, and TTS (including `macos-*` AVSpeech voices on darwin-arm64) identically to the npm install. Speaker diarization (`kesha install --diarize` / `--speakers`) is not yet wired into the Nix build — the `kesha-diarize` Swift sidecar needs network-fetched FluidAudio at build time, which the Nix sandbox forbids; use the Bun install path (`bun add -g @drakulavich/kesha-voice-kit`) on darwin-arm64 if you need that feature (tracked alongside [#199](https://github.com/drakulavich/kesha-voice-kit/issues/199)).
|
|
55
|
+
|
|
56
|
+
### Engine only (no Bun, no Node)
|
|
57
|
+
For users who just want the Rust binary:
|
|
58
|
+
```bash
|
|
59
|
+
nix build github:drakulavich/kesha-voice-kit#kesha-engine
|
|
60
|
+
./result/bin/kesha-engine --help
|
|
61
|
+
./result/bin/kesha-engine --capabilities-json # see which backends compiled in
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Development shell
|
|
65
|
+
```bash
|
|
66
|
+
nix develop github:drakulavich/kesha-voice-kit
|
|
67
|
+
# Now you have: pinned rustc/cargo (via rust-overlay), bun, protoc, cmake, pkg-config, libclang
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
**Why Nix?**
|
|
71
|
+
- ✅ Reproducible builds across Linux/macOS
|
|
72
|
+
- ✅ All native deps (onnxruntime, protobuf, abseil) handled automatically
|
|
73
|
+
- ✅ No "works on my machine" — same `flake.nix` = identical results everywhere
|
|
74
|
+
|
|
35
75
|
## Speech-to-text
|
|
36
76
|
|
|
37
77
|
```bash
|
|
@@ -98,6 +138,18 @@ kesha say --voice ru-vosk-m02 --ssml \
|
|
|
98
138
|
|
|
99
139
|
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).
|
|
100
140
|
|
|
141
|
+
**Speech rate via SSML** (`ru-vosk-*` and `en-*` voices):
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
kesha say --voice ru-vosk-m02 --ssml \
|
|
145
|
+
'<speak><prosody rate="slow">Привет, как дела.</prosody></speak>' --out slow.wav
|
|
146
|
+
|
|
147
|
+
kesha say --voice en-am_michael --ssml \
|
|
148
|
+
'<speak><prosody rate="x-fast">Read this fast.</prosody></speak>' --out fast.wav
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
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×.
|
|
152
|
+
|
|
101
153
|
macOS system voices, SSML, voice listing, and the full voice catalogue: [docs/tts.md](docs/tts.md).
|
|
102
154
|
|
|
103
155
|
## Performance
|
package/SKILL.md
CHANGED
|
@@ -100,6 +100,8 @@ Format is also inferred from `--out` extension (`.ogg` / `.opus` / `.oga` → OG
|
|
|
100
100
|
|
|
101
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).
|
|
102
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
|
+
|
|
103
105
|
## Language detection standalone
|
|
104
106
|
|
|
105
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.
|
|
3
|
+
"version": "1.14.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.
|
|
33
|
+
"version": "1.14.0"
|
|
34
34
|
},
|
|
35
35
|
"scripts": {
|
|
36
36
|
"test": "bun test",
|
package/src/cli/say.ts
CHANGED
|
@@ -1,31 +1,8 @@
|
|
|
1
1
|
import { defineCommand } from "citty";
|
|
2
2
|
import { detectTextLanguageEngine, getEngineBinPath } from "../engine";
|
|
3
3
|
import { log } from "../log";
|
|
4
|
-
import { say, SayError, type SayFormat } from "../
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Darwin defaults to AVSpeech Milena — zero install, no model download required.
|
|
8
|
-
* Linux/Windows fall through to Vosk-TTS `ru-vosk-m02` (male, per CLAUDE.md
|
|
9
|
-
* "DEFAULT TTS VOICES MUST BE MALE"; replaces Piper-ruslan as of #213).
|
|
10
|
-
*/
|
|
11
|
-
const RU_DARWIN_FALLBACK_VOICE = "macos-com.apple.voice.compact.ru-RU.Milena";
|
|
12
|
-
|
|
13
|
-
/** Map a detected language code to a default voice id. Unknown / low-confidence → undefined. */
|
|
14
|
-
export function pickVoiceForLang(
|
|
15
|
-
code: string | undefined,
|
|
16
|
-
confidence: number,
|
|
17
|
-
platform: NodeJS.Platform = process.platform,
|
|
18
|
-
): string | undefined {
|
|
19
|
-
if (!code || confidence < 0.5) return undefined;
|
|
20
|
-
switch (code) {
|
|
21
|
-
case "en":
|
|
22
|
-
return "en-am_michael";
|
|
23
|
-
case "ru":
|
|
24
|
-
return platform === "darwin" ? RU_DARWIN_FALLBACK_VOICE : "ru-vosk-m02";
|
|
25
|
-
default:
|
|
26
|
-
return undefined;
|
|
27
|
-
}
|
|
28
|
-
}
|
|
4
|
+
import { say, SayError, type SayFormat } from "../synth";
|
|
5
|
+
import { pickVoiceForLang } from "../voice-routing";
|
|
29
6
|
|
|
30
7
|
/** Run NLLanguageRecognizer (via engine) on the text and pick a default voice. */
|
|
31
8
|
async function autoRouteVoice(text: string): Promise<string | undefined> {
|
package/src/cli.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
// tests and lib.ts keep importing from "./cli" via these re-exports so the
|
|
5
5
|
// public surface is unchanged.
|
|
6
6
|
export { installCommand } from "./cli/install";
|
|
7
|
-
export { sayCommand
|
|
7
|
+
export { sayCommand } from "./cli/say";
|
|
8
|
+
export { pickVoiceForLang } from "./voice-routing";
|
|
8
9
|
export { statusCommand } from "./cli/status";
|
|
9
10
|
export { mainCommand, detectLanguage, checkLanguageMismatch } from "./cli/main";
|
|
10
11
|
export { runCli } from "./cli/dispatch";
|
package/src/engine-install.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { dirname, join } from "path";
|
|
2
|
-
import { existsSync, mkdirSync, chmodSync } from "fs";
|
|
3
|
-
import {
|
|
2
|
+
import { existsSync, mkdirSync, chmodSync, accessSync, constants } from "fs";
|
|
3
|
+
import {
|
|
4
|
+
getEngineBinPath,
|
|
5
|
+
getEngineCapabilities,
|
|
6
|
+
TRANSCRIBE_DIARIZE_FEATURE,
|
|
7
|
+
} from "./engine";
|
|
4
8
|
import { log } from "./log";
|
|
5
9
|
import { streamResponseToFile } from "./progress";
|
|
6
10
|
import {
|
|
@@ -151,19 +155,56 @@ export async function downloadEngine(
|
|
|
151
155
|
: "unknown";
|
|
152
156
|
|
|
153
157
|
const installedVersion = readInstalledEngineVersion(binPath);
|
|
154
|
-
const
|
|
155
|
-
|
|
158
|
+
const engineDir = dirname(binPath);
|
|
159
|
+
|
|
160
|
+
// Detect a read-only engine directory (e.g., a Nix-store install —
|
|
161
|
+
// `KESHA_ENGINE_BIN` points into `/nix/store/.../bin/`). Used twice
|
|
162
|
+
// below: to honor `--no-cache` only when the engine is writable, and
|
|
163
|
+
// to skip the sidecar top-up that would otherwise emit confusing
|
|
164
|
+
// "install failed" warnings for paths we physically can't write to.
|
|
165
|
+
let canWriteEngineDir = true;
|
|
166
|
+
if (existsSync(engineDir)) {
|
|
167
|
+
try {
|
|
168
|
+
accessSync(engineDir, constants.W_OK);
|
|
169
|
+
} catch {
|
|
170
|
+
canWriteEngineDir = false;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const versionMatches =
|
|
175
|
+
existsSync(binPath) && installedVersion === engineVersion;
|
|
176
|
+
// When the engine sits on a read-only filesystem at the matching
|
|
177
|
+
// version, `--no-cache` can't re-download it without an EROFS crash
|
|
178
|
+
// and there's nothing to refresh anyway — treat as cache-valid and
|
|
179
|
+
// forward `--no-cache` to the model install step below.
|
|
180
|
+
const cacheValid = versionMatches && (!noCache || !canWriteEngineDir);
|
|
156
181
|
|
|
157
182
|
if (cacheValid) {
|
|
158
|
-
|
|
183
|
+
if (noCache && !canWriteEngineDir) {
|
|
184
|
+
log.info(
|
|
185
|
+
`Engine binary at v${engineVersion} is on a read-only filesystem; --no-cache skipped for engine (still forwarded to model installs).`,
|
|
186
|
+
);
|
|
187
|
+
} else {
|
|
188
|
+
log.success(`Engine binary already installed (v${engineVersion}).`);
|
|
189
|
+
}
|
|
159
190
|
// Top up any sidecars missing from this cached install. Pre-#141 / pre-#199
|
|
160
191
|
// engines never shipped them, so a cache-valid binary may still need
|
|
161
192
|
// fetching. Run independent fetches concurrently — same shape as the
|
|
162
193
|
// cold path below.
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
)
|
|
166
|
-
|
|
194
|
+
//
|
|
195
|
+
// Skip the top-up entirely when the engine sits in a read-only
|
|
196
|
+
// filesystem (e.g., a Nix-store install). Those installs stage the
|
|
197
|
+
// supported sidecars at build time; writing more is impossible, and
|
|
198
|
+
// attempting it would emit a confusing "install failed" warning for
|
|
199
|
+
// a feature the Nix README explicitly documents as unsupported
|
|
200
|
+
// (diarize on Nix needs network access at build time, which the
|
|
201
|
+
// sandbox forbids).
|
|
202
|
+
if (canWriteEngineDir) {
|
|
203
|
+
const missing = SIDECARS.filter(
|
|
204
|
+
(s) => !existsSync(join(engineDir, s.fileBasename)),
|
|
205
|
+
);
|
|
206
|
+
await Promise.all(missing.map((s) => downloadSidecar(s, binPath, engineVersion)));
|
|
207
|
+
}
|
|
167
208
|
} else {
|
|
168
209
|
// Log why we're downloading — helps diagnose surprising re-downloads.
|
|
169
210
|
if (existsSync(binPath) && installedVersion && installedVersion !== engineVersion) {
|
|
@@ -225,6 +266,29 @@ export async function downloadEngine(
|
|
|
225
266
|
}
|
|
226
267
|
}
|
|
227
268
|
|
|
269
|
+
// Catch the case where the platform check passed (darwin-arm64) but the
|
|
270
|
+
// engine itself was built without `system_diarize` — e.g., the Nix build,
|
|
271
|
+
// which compiles `coreml,tts,system_tts` and intentionally omits diarize
|
|
272
|
+
// because the FluidAudio CoreML weights need network at build time and the
|
|
273
|
+
// Nix sandbox forbids it. Without this guard, `kesha-engine install
|
|
274
|
+
// --diarize` would fail with clap's generic "unexpected argument" error.
|
|
275
|
+
if (options.diarize) {
|
|
276
|
+
const caps = await getEngineCapabilities();
|
|
277
|
+
// Treat null (pre-capabilities-JSON engine, or capability probe failed)
|
|
278
|
+
// the same as an engine that advertised features but omitted ours —
|
|
279
|
+
// forwarding `--diarize` to a binary that doesn't understand it would
|
|
280
|
+
// surface as clap's generic "unexpected argument" error.
|
|
281
|
+
if (!caps || !caps.features.includes(TRANSCRIBE_DIARIZE_FEATURE)) {
|
|
282
|
+
throw new Error(
|
|
283
|
+
"--diarize is not supported by the installed engine: it was built " +
|
|
284
|
+
"without the 'system_diarize' feature (the Nix build is one such " +
|
|
285
|
+
"case — see README's Nix Install section).\n" +
|
|
286
|
+
" Fix: install via the npm release with `bun add -g @drakulavich/kesha-voice-kit`, " +
|
|
287
|
+
"which ships the diarize-enabled engine on darwin-arm64.",
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
228
292
|
log.progress("Installing models...");
|
|
229
293
|
const installArgs = [
|
|
230
294
|
"install",
|
package/src/engine.ts
CHANGED
|
@@ -66,6 +66,15 @@ async function runEngine(args: string[]): Promise<{ stdout: string; stderr: stri
|
|
|
66
66
|
]);
|
|
67
67
|
|
|
68
68
|
log.debug(`exit=${exitCode} dt=${Math.round(performance.now() - startedAt)}ms args=${JSON.stringify(args)}`);
|
|
69
|
+
|
|
70
|
+
// #275 D4: surface engine stderr on the success path so warnings like
|
|
71
|
+
// `hint: audio is 180s`, `Model mirror active:`, and the dtrace lines
|
|
72
|
+
// emitted under KESHA_DEBUG=1 reach the user. On non-zero exit we leave
|
|
73
|
+
// the buffer for callers to fold into a thrown Error — otherwise the
|
|
74
|
+
// user would see the warning AND a duplicate inside the error message.
|
|
75
|
+
if (exitCode === 0 && stderr.length > 0) {
|
|
76
|
+
process.stderr.write(stderr.endsWith("\n") ? stderr : stderr + "\n");
|
|
77
|
+
}
|
|
69
78
|
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode };
|
|
70
79
|
}
|
|
71
80
|
|
package/src/lib.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { downloadEngine } from "./engine-install";
|
|
|
9
9
|
export type { TranscribeOptions };
|
|
10
10
|
export type { TranscriptionOutput, TranscriptionSegment } from "./engine";
|
|
11
11
|
export { downloadEngine as downloadModel };
|
|
12
|
-
export { say, type SayOptions, SayError } from "./
|
|
12
|
+
export { say, type SayOptions, SayError } from "./synth";
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* Encode a `TranscribeResult[]` as TOON (#138). Same data shape as the
|
package/src/log.ts
CHANGED
|
@@ -4,10 +4,19 @@ import pc from "picocolors";
|
|
|
4
4
|
* Debug mode (#148): when `KESHA_DEBUG` is truthy OR the caller has flipped
|
|
5
5
|
* `log.debugEnabled = true` (via `--debug`), `log.debug()` writes structured
|
|
6
6
|
* trace lines to stderr. Otherwise it's a no-op. Stdout is never touched.
|
|
7
|
+
*
|
|
8
|
+
* Grammar (#275 D9): values that turn debug OFF — empty, `"0"`, `"false"`,
|
|
9
|
+
* `"no"`, `"off"`, all matched **case-insensitively** after trimming. Any
|
|
10
|
+
* other non-empty value turns debug ON. The Rust engine mirrors this list
|
|
11
|
+
* verbatim in `rust/src/debug.rs` so `KESHA_DEBUG=False` flips both sides
|
|
12
|
+
* the same direction.
|
|
7
13
|
*/
|
|
14
|
+
const KESHA_DEBUG_OFF_VALUES = new Set(["", "0", "false", "no", "off"]);
|
|
15
|
+
|
|
8
16
|
function envDebug(): boolean {
|
|
9
17
|
const v = process.env.KESHA_DEBUG;
|
|
10
|
-
|
|
18
|
+
if (v === undefined) return false;
|
|
19
|
+
return !KESHA_DEBUG_OFF_VALUES.has(v.trim().toLowerCase());
|
|
11
20
|
}
|
|
12
21
|
|
|
13
22
|
export const log = {
|
package/src/{say.ts → synth.ts}
RENAMED
|
@@ -41,8 +41,11 @@ export interface SayOptions {
|
|
|
41
41
|
/**
|
|
42
42
|
* Disable acronym auto-expansion for `ru-vosk-*` and `en-*` voices.
|
|
43
43
|
* When true, passes `--no-expand-abbrev` to the engine (requires engine
|
|
44
|
-
* capability `tts.ru_acronym_expansion` or `tts.en_acronym_expansion
|
|
45
|
-
*
|
|
44
|
+
* capability `tts.ru_acronym_expansion` or `tts.en_acronym_expansion`).
|
|
45
|
+
* On older engines that don't advertise the capability, the flag is
|
|
46
|
+
* dropped from argv and `log.warn` surfaces the drop on every
|
|
47
|
+
* invocation (post-#275 D3). `<say-as interpret-as="characters">`
|
|
48
|
+
* still works regardless of this flag.
|
|
46
49
|
*/
|
|
47
50
|
noExpandAbbrev?: boolean;
|
|
48
51
|
}
|
|
@@ -65,8 +68,12 @@ export function buildSayArgs(o: SayOptions, capabilities?: EngineCapabilities |
|
|
|
65
68
|
if (supportsExpand) {
|
|
66
69
|
args.push("--no-expand-abbrev");
|
|
67
70
|
} else {
|
|
68
|
-
|
|
69
|
-
|
|
71
|
+
// CLAUDE.md "NEVER SWALLOW ERRORS": the user explicitly passed the flag.
|
|
72
|
+
// Silent drop with only `log.debug` made the flag look effective on old
|
|
73
|
+
// engines (#275 D3). Surface it as a warning so a CI script or human
|
|
74
|
+
// user sees the mismatch on every invocation, not only with --debug.
|
|
75
|
+
log.warn(
|
|
76
|
+
"--no-expand-abbrev requires kesha-engine ≥ 1.10.0 (advertises no tts.ru_acronym_expansion / tts.en_acronym_expansion capability); flag ignored",
|
|
70
77
|
);
|
|
71
78
|
}
|
|
72
79
|
}
|
|
@@ -121,6 +128,14 @@ export async function say(opts: SayOptions): Promise<Uint8Array> {
|
|
|
121
128
|
]);
|
|
122
129
|
|
|
123
130
|
log.debug(`exit=${exitCode} dt=${Math.round(performance.now() - startedAt)}ms bytes=${stdoutBuf.byteLength}`);
|
|
131
|
+
|
|
132
|
+
// #275 D4: surface engine stderr on the success path so warnings like
|
|
133
|
+
// `Model mirror active:` and the dtrace lines emitted under
|
|
134
|
+
// KESHA_DEBUG=1 reach the user. Errors keep their existing path
|
|
135
|
+
// through `SayError.stderr` so we don't double-print.
|
|
136
|
+
if (exitCode === 0 && stderrText.length > 0) {
|
|
137
|
+
process.stderr.write(stderrText.endsWith("\n") ? stderrText : stderrText + "\n");
|
|
138
|
+
}
|
|
124
139
|
if (exitCode !== 0) {
|
|
125
140
|
throw new SayError(
|
|
126
141
|
stderrText.trim() || `kesha-engine say exited ${exitCode}`,
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Darwin defaults to AVSpeech Milena — zero install, no model download required.
|
|
3
|
+
* Linux/Windows fall through to Vosk-TTS `ru-vosk-m02` (male, per CLAUDE.md
|
|
4
|
+
* "DEFAULT TTS VOICES MUST BE MALE"; replaces Piper-ruslan as of #213).
|
|
5
|
+
*/
|
|
6
|
+
const RU_DARWIN_FALLBACK_VOICE = "macos-com.apple.voice.compact.ru-RU.Milena";
|
|
7
|
+
|
|
8
|
+
/** Map a detected language code to a default voice id. Unknown / low-confidence → undefined. */
|
|
9
|
+
export function pickVoiceForLang(
|
|
10
|
+
code: string | undefined,
|
|
11
|
+
confidence: number,
|
|
12
|
+
platform: NodeJS.Platform = process.platform,
|
|
13
|
+
): string | undefined {
|
|
14
|
+
if (!code || confidence < 0.5) return undefined;
|
|
15
|
+
switch (code) {
|
|
16
|
+
case "en":
|
|
17
|
+
return "en-am_michael";
|
|
18
|
+
case "ru":
|
|
19
|
+
return platform === "darwin" ? RU_DARWIN_FALLBACK_VOICE : "ru-vosk-m02";
|
|
20
|
+
default:
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
}
|