@drakulavich/kesha-voice-kit 1.13.0 → 1.15.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 +19 -29
- package/package.json +2 -2
- package/src/cli/say.ts +2 -25
- package/src/cli.ts +2 -1
- package/src/engine-install.ts +174 -9
- package/src/engine.ts +9 -0
- package/src/lib.ts +1 -1
- package/src/log.ts +26 -2
- package/src/{say.ts → synth.ts} +19 -4
- package/src/voice-routing.ts +23 -0
package/README.md
CHANGED
|
@@ -22,49 +22,39 @@
|
|
|
22
22
|
|
|
23
23
|
Runtime: **[Bun](https://bun.sh)** >= 1.3.0.
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
curl -fsSL https://bun.sh/install | bash # skip if Bun is already installed
|
|
25
|
+
Install Bun (skip if already installed) — pick one:
|
|
27
26
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
```bash
|
|
28
|
+
# Linux & macOS
|
|
29
|
+
curl -fsSL https://bun.sh/install | bash # upstream installer
|
|
30
|
+
brew install oven-sh/bun/bun # Homebrew
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
33
|
+
```powershell
|
|
34
|
+
# Windows
|
|
35
|
+
powershell -c "irm bun.sh/install.ps1 | iex"
|
|
43
36
|
```
|
|
44
37
|
|
|
45
|
-
|
|
38
|
+
Then install Kesha:
|
|
39
|
+
|
|
46
40
|
```bash
|
|
47
|
-
|
|
41
|
+
bun add -g @drakulavich/kesha-voice-kit
|
|
48
42
|
kesha install # downloads engine + models
|
|
49
43
|
kesha audio.ogg # transcript to stdout
|
|
50
44
|
```
|
|
51
45
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
46
|
+
Air-gapped or behind a corporate mirror? See [docs/model-mirror.md](docs/model-mirror.md).
|
|
47
|
+
|
|
48
|
+
## Nix Install
|
|
49
|
+
|
|
50
|
+
Alternative reproducible-build path on `aarch64-darwin` / `x86_64-linux`:
|
|
57
51
|
|
|
58
|
-
### Build only
|
|
59
52
|
```bash
|
|
60
|
-
nix
|
|
61
|
-
|
|
53
|
+
nix run github:drakulavich/kesha-voice-kit -- install # downloads models (engine is bundled)
|
|
54
|
+
nix run github:drakulavich/kesha-voice-kit -- audio.ogg # transcribe
|
|
62
55
|
```
|
|
63
56
|
|
|
64
|
-
|
|
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
|
|
57
|
+
Full recipes (one-liner, profile install, engine-only, dev shell) live in [docs/nix-install.md](docs/nix-install.md).
|
|
68
58
|
|
|
69
59
|
## Speech-to-text
|
|
70
60
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drakulavich/kesha-voice-kit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.15.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.15.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 {
|
|
@@ -72,6 +76,85 @@ const SIDECARS: SidecarSpec[] = [
|
|
|
72
76
|
},
|
|
73
77
|
];
|
|
74
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Make a freshly-downloaded Mach-O runnable on macOS 15+ Sequoia.
|
|
81
|
+
*
|
|
82
|
+
* The release binaries ship `Signature=adhoc` (built via `cargo build`,
|
|
83
|
+
* no Apple Developer ID). When fetched via HTTPS into `~/.cache/...`,
|
|
84
|
+
* macOS attaches a `com.apple.provenance` xattr identifying the download
|
|
85
|
+
* source. Combined with stricter Gatekeeper policy on macOS 15+ Sequoia,
|
|
86
|
+
* the resulting "untrusted downloaded ad-hoc binary" is killed with
|
|
87
|
+
* SIGKILL on first invocation (exit 137), with no log line — Gatekeeper
|
|
88
|
+
* denies before Rust's main runs.
|
|
89
|
+
*
|
|
90
|
+
* Two independent fixes run in sequence; both are best-effort:
|
|
91
|
+
*
|
|
92
|
+
* 1. `codesign --force --sign - <path>` — re-applies the ad-hoc
|
|
93
|
+
* signature with the user's own host identity, defeating the trust
|
|
94
|
+
* mismatch.
|
|
95
|
+
* 2. `xattr -d com.apple.provenance <path>` — strips the provenance
|
|
96
|
+
* marker entirely. Cheaper than codesign and works even when
|
|
97
|
+
* `codesign` is absent from PATH (corporate-locked machine, minimal
|
|
98
|
+
* CI image).
|
|
99
|
+
*
|
|
100
|
+
* Either step alone has unblocked the SIGKILL in field reports, so we
|
|
101
|
+
* run both. If both fail, surface the manual recovery commands in
|
|
102
|
+
* stderr. Linux/Windows paths never reach this function.
|
|
103
|
+
*/
|
|
104
|
+
function darwinTrustBinary(path: string, displayName: string): void {
|
|
105
|
+
if (process.platform !== "darwin") return;
|
|
106
|
+
let codesignOk = false;
|
|
107
|
+
let xattrOk = false;
|
|
108
|
+
try {
|
|
109
|
+
const proc = Bun.spawnSync(["codesign", "--force", "--sign", "-", path], {
|
|
110
|
+
stdout: "pipe",
|
|
111
|
+
stderr: "pipe",
|
|
112
|
+
});
|
|
113
|
+
codesignOk = proc.exitCode === 0;
|
|
114
|
+
if (!codesignOk) {
|
|
115
|
+
const stderr = new TextDecoder().decode(proc.stderr).trim();
|
|
116
|
+
log.debug(`codesign on ${displayName} exited ${proc.exitCode}: ${stderr}`);
|
|
117
|
+
}
|
|
118
|
+
} catch (e) {
|
|
119
|
+
log.debug(
|
|
120
|
+
`codesign on ${displayName} threw: ${e instanceof Error ? e.message : e}`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
// `xattr -d` returns exit 1 with "No such xattr" if the attribute is
|
|
125
|
+
// already absent (e.g. the file was placed via `bun link` instead of
|
|
126
|
+
// downloaded). That's a success outcome for our purposes — the
|
|
127
|
+
// attribute we wanted gone is already gone — so treat exit 1 as ok.
|
|
128
|
+
const proc = Bun.spawnSync(
|
|
129
|
+
["xattr", "-d", "com.apple.provenance", path],
|
|
130
|
+
{ stdout: "pipe", stderr: "pipe" },
|
|
131
|
+
);
|
|
132
|
+
const stderr = new TextDecoder().decode(proc.stderr).trim();
|
|
133
|
+
xattrOk =
|
|
134
|
+
proc.exitCode === 0 ||
|
|
135
|
+
(proc.exitCode === 1 && /No such xattr/i.test(stderr));
|
|
136
|
+
if (!xattrOk) {
|
|
137
|
+
log.debug(`xattr -d on ${displayName} exited ${proc.exitCode}: ${stderr}`);
|
|
138
|
+
}
|
|
139
|
+
} catch (e) {
|
|
140
|
+
log.debug(
|
|
141
|
+
`xattr -d on ${displayName} threw: ${e instanceof Error ? e.message : e}`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
if (!codesignOk && !xattrOk) {
|
|
145
|
+
// Single-quote the path in the manual-fix hint so spaces / shell
|
|
146
|
+
// metachars in `~/.cache/.../bin/...` don't break paste-into-shell.
|
|
147
|
+
// POSIX single-quote escape: close the quote, insert escaped `'`,
|
|
148
|
+
// re-open. Cheap and correct on bash/zsh.
|
|
149
|
+
const q = (p: string) => `'${p.replace(/'/g, `'\\''`)}'`;
|
|
150
|
+
log.warn(
|
|
151
|
+
`Could not unblock ${displayName} for macOS Gatekeeper (both codesign ` +
|
|
152
|
+
`and xattr failed); if the binary refuses to run, manually run: ` +
|
|
153
|
+
`codesign --force --sign - ${q(path)} && xattr -d com.apple.provenance ${q(path)}`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
75
158
|
/**
|
|
76
159
|
* Fetch a single Swift sidecar and place it next to the engine binary on
|
|
77
160
|
* darwin-arm64. Best-effort: 404s (older engine versions predate this
|
|
@@ -115,6 +198,7 @@ async function downloadSidecar(
|
|
|
115
198
|
try {
|
|
116
199
|
await streamResponseToFile(res, sidecarPath, spec.displayName);
|
|
117
200
|
chmodSync(sidecarPath, 0o755);
|
|
201
|
+
darwinTrustBinary(sidecarPath, spec.displayName);
|
|
118
202
|
log.success(`${spec.displayName} installed (${spec.availableHint}).`);
|
|
119
203
|
} catch (e) {
|
|
120
204
|
log.warn(
|
|
@@ -151,19 +235,76 @@ export async function downloadEngine(
|
|
|
151
235
|
: "unknown";
|
|
152
236
|
|
|
153
237
|
const installedVersion = readInstalledEngineVersion(binPath);
|
|
154
|
-
const
|
|
155
|
-
|
|
238
|
+
const engineDir = dirname(binPath);
|
|
239
|
+
|
|
240
|
+
// Detect a read-only engine directory (e.g., a Nix-store install —
|
|
241
|
+
// `KESHA_ENGINE_BIN` points into `/nix/store/.../bin/`). Used twice
|
|
242
|
+
// below: to honor `--no-cache` only when the engine is writable, and
|
|
243
|
+
// to skip the sidecar top-up that would otherwise emit confusing
|
|
244
|
+
// "install failed" warnings for paths we physically can't write to.
|
|
245
|
+
let canWriteEngineDir = true;
|
|
246
|
+
if (existsSync(engineDir)) {
|
|
247
|
+
try {
|
|
248
|
+
accessSync(engineDir, constants.W_OK);
|
|
249
|
+
} catch {
|
|
250
|
+
canWriteEngineDir = false;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const versionMatches =
|
|
255
|
+
existsSync(binPath) && installedVersion === engineVersion;
|
|
256
|
+
// When the engine sits on a read-only filesystem at the matching
|
|
257
|
+
// version, `--no-cache` can't re-download it without an EROFS crash
|
|
258
|
+
// and there's nothing to refresh anyway — treat as cache-valid and
|
|
259
|
+
// forward `--no-cache` to the model install step below.
|
|
260
|
+
const cacheValid = versionMatches && (!noCache || !canWriteEngineDir);
|
|
156
261
|
|
|
157
262
|
if (cacheValid) {
|
|
158
|
-
|
|
263
|
+
if (noCache && !canWriteEngineDir) {
|
|
264
|
+
log.info(
|
|
265
|
+
`Engine binary at v${engineVersion} is on a read-only filesystem; --no-cache skipped for engine (still forwarded to model installs).`,
|
|
266
|
+
);
|
|
267
|
+
} else {
|
|
268
|
+
log.success(`Engine binary already installed (v${engineVersion}).`);
|
|
269
|
+
}
|
|
270
|
+
// Re-run the macOS trust step against the cached binary too. Reason:
|
|
271
|
+
// a user who upgraded to macOS 15+ Sequoia AFTER installing kesha
|
|
272
|
+
// would have a v<X> binary on disk with `com.apple.provenance` still
|
|
273
|
+
// attached, and `kesha install` would normally bail at "already
|
|
274
|
+
// installed" without healing the SIGKILL. Idempotent — re-signing
|
|
275
|
+
// an already-correctly-signed binary is a ~10 ms no-op; `xattr -d`
|
|
276
|
+
// on an absent attr is handled (exit 1 + "No such xattr" treated
|
|
277
|
+
// as success in `darwinTrustBinary`). Skipped on read-only
|
|
278
|
+
// filesystems (Nix-store installs) — no write access anyway.
|
|
279
|
+
if (canWriteEngineDir && existsSync(binPath)) {
|
|
280
|
+
darwinTrustBinary(binPath, "kesha-engine binary");
|
|
281
|
+
}
|
|
159
282
|
// Top up any sidecars missing from this cached install. Pre-#141 / pre-#199
|
|
160
283
|
// engines never shipped them, so a cache-valid binary may still need
|
|
161
284
|
// fetching. Run independent fetches concurrently — same shape as the
|
|
162
285
|
// cold path below.
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
)
|
|
166
|
-
|
|
286
|
+
//
|
|
287
|
+
// Skip the top-up entirely when the engine sits in a read-only
|
|
288
|
+
// filesystem (e.g., a Nix-store install). Those installs stage the
|
|
289
|
+
// supported sidecars at build time; writing more is impossible, and
|
|
290
|
+
// attempting it would emit a confusing "install failed" warning for
|
|
291
|
+
// a feature the Nix README explicitly documents as unsupported
|
|
292
|
+
// (diarize on Nix needs network access at build time, which the
|
|
293
|
+
// sandbox forbids).
|
|
294
|
+
if (canWriteEngineDir) {
|
|
295
|
+
const missing = SIDECARS.filter(
|
|
296
|
+
(s) => !existsSync(join(engineDir, s.fileBasename)),
|
|
297
|
+
);
|
|
298
|
+
await Promise.all(missing.map((s) => downloadSidecar(s, binPath, engineVersion)));
|
|
299
|
+
// The cache-valid branch also re-trusts any sidecars that already
|
|
300
|
+
// exist alongside the engine — same upgrade-to-Sequoia scenario as
|
|
301
|
+
// above. The `missing.map` above only handles NEW sidecars; this
|
|
302
|
+
// loop handles ALREADY-PRESENT ones.
|
|
303
|
+
for (const s of SIDECARS) {
|
|
304
|
+
const p = join(engineDir, s.fileBasename);
|
|
305
|
+
if (existsSync(p)) darwinTrustBinary(p, s.displayName);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
167
308
|
} else {
|
|
168
309
|
// Log why we're downloading — helps diagnose surprising re-downloads.
|
|
169
310
|
if (existsSync(binPath) && installedVersion && installedVersion !== engineVersion) {
|
|
@@ -211,6 +352,7 @@ export async function downloadEngine(
|
|
|
211
352
|
|
|
212
353
|
await streamResponseToFile(res, binPath, "kesha-engine binary");
|
|
213
354
|
chmodSync(binPath, 0o755);
|
|
355
|
+
darwinTrustBinary(binPath, "kesha-engine binary");
|
|
214
356
|
writeInstalledEngineVersion(binPath, engineVersion);
|
|
215
357
|
log.success(`Engine binary downloaded (v${engineVersion}).`);
|
|
216
358
|
await Promise.all(sidecarPromises);
|
|
@@ -225,6 +367,29 @@ export async function downloadEngine(
|
|
|
225
367
|
}
|
|
226
368
|
}
|
|
227
369
|
|
|
370
|
+
// Catch the case where the platform check passed (darwin-arm64) but the
|
|
371
|
+
// engine itself was built without `system_diarize` — e.g., the Nix build,
|
|
372
|
+
// which compiles `coreml,tts,system_tts` and intentionally omits diarize
|
|
373
|
+
// because the FluidAudio CoreML weights need network at build time and the
|
|
374
|
+
// Nix sandbox forbids it. Without this guard, `kesha-engine install
|
|
375
|
+
// --diarize` would fail with clap's generic "unexpected argument" error.
|
|
376
|
+
if (options.diarize) {
|
|
377
|
+
const caps = await getEngineCapabilities();
|
|
378
|
+
// Treat null (pre-capabilities-JSON engine, or capability probe failed)
|
|
379
|
+
// the same as an engine that advertised features but omitted ours —
|
|
380
|
+
// forwarding `--diarize` to a binary that doesn't understand it would
|
|
381
|
+
// surface as clap's generic "unexpected argument" error.
|
|
382
|
+
if (!caps || !caps.features.includes(TRANSCRIBE_DIARIZE_FEATURE)) {
|
|
383
|
+
throw new Error(
|
|
384
|
+
"--diarize is not supported by the installed engine: it was built " +
|
|
385
|
+
"without the 'system_diarize' feature (the Nix build is one such " +
|
|
386
|
+
"case — see docs/nix-install.md).\n" +
|
|
387
|
+
" Fix: install via the npm release with `bun add -g @drakulavich/kesha-voice-kit`, " +
|
|
388
|
+
"which ships the diarize-enabled engine on darwin-arm64.",
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
228
393
|
log.progress("Installing models...");
|
|
229
394
|
const installArgs = [
|
|
230
395
|
"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,12 +4,28 @@ 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
|
|
|
22
|
+
/**
|
|
23
|
+
* Module-load timestamp for relative-since-start prefixes on debug lines.
|
|
24
|
+
* The CLI runs nothing of substance before this file is imported, so this
|
|
25
|
+
* is effectively process-start. Recorded once.
|
|
26
|
+
*/
|
|
27
|
+
const PROCESS_T0_MS = performance.now();
|
|
28
|
+
|
|
13
29
|
export const log = {
|
|
14
30
|
info: (msg: string) => console.log(msg),
|
|
15
31
|
success: (msg: string) => console.log(pc.green(msg)),
|
|
@@ -20,7 +36,15 @@ export const log = {
|
|
|
20
36
|
debugEnabled: false,
|
|
21
37
|
debug(msg: string): void {
|
|
22
38
|
if (this.debugEnabled || envDebug()) {
|
|
23
|
-
|
|
39
|
+
// `[debug +Nms]` prefix sits on the CLI process's own timeline so
|
|
40
|
+
// the reader can see when each line fired. The Rust engine emits
|
|
41
|
+
// the same `+Nms` shape from `rust/src/debug.rs::trace_fmt`, but
|
|
42
|
+
// anchored to its own process start — the two axes are
|
|
43
|
+
// independent. For "duration between two events on the same
|
|
44
|
+
// process", read the prefix difference; for cross-process spans,
|
|
45
|
+
// the spawn→exit `dt=Nms` inside the message remains authoritative.
|
|
46
|
+
const t = Math.round(performance.now() - PROCESS_T0_MS);
|
|
47
|
+
console.error(pc.dim(`[debug +${t}ms] ${msg}`));
|
|
24
48
|
}
|
|
25
49
|
},
|
|
26
50
|
};
|
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
|
+
}
|