@drakulavich/kesha-voice-kit 1.1.3 → 1.2.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 +35 -1
- package/package.json +2 -2
- package/src/cli.ts +36 -0
- package/src/engine.ts +4 -0
- package/src/log.ts +17 -0
- package/src/say.ts +7 -0
package/README.md
CHANGED
|
@@ -93,11 +93,45 @@ kesha say --voice en-af_heart "text" # explicit voice overrides auto-routing
|
|
|
93
93
|
kesha say --list-voices
|
|
94
94
|
```
|
|
95
95
|
|
|
96
|
-
Output format: WAV mono float32 (24 kHz for Kokoro, 22.05 kHz for Piper). OGG/Opus
|
|
96
|
+
Output format: WAV mono float32 (24 kHz for Kokoro, 22.05 kHz for Piper). OGG/Opus and MP3 are tracked in follow-up issues. Static-linking of `espeak-ng` to remove the system dep is [#124](https://github.com/drakulavich/kesha-voice-kit/issues/124).
|
|
97
97
|
|
|
98
98
|
**Supported voices:**
|
|
99
99
|
- English: `en-af_heart` (default), plus any Kokoro voice you download into `~/.cache/kesha/models/kokoro-82m/voices/`
|
|
100
100
|
- 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.
|
|
102
|
+
|
|
103
|
+
### macOS system voices (preview)
|
|
104
|
+
|
|
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).
|
|
106
|
+
|
|
107
|
+
```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
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
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.
|
|
114
|
+
|
|
115
|
+
Quality tradeoff is honest: macOS system voices are notification-grade. Use them when you want zero-install TTS on macOS; keep Kokoro/Piper for anything that needs to sound good.
|
|
116
|
+
|
|
117
|
+
### SSML (preview)
|
|
118
|
+
|
|
119
|
+
`kesha say --ssml` accepts [SSML](https://www.w3.org/TR/speech-synthesis11/) for pauses and text-structuring. v1 is deliberately small:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
kesha say --ssml '<speak>Hello <break time="500ms"/> world.</speak>'
|
|
123
|
+
kesha say --ssml --voice ru-denis '<speak>Привет <break time="1s"/> мир.</speak>'
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
| Tag | Status |
|
|
127
|
+
|---|---|
|
|
128
|
+
| `<speak>` | ✅ required root |
|
|
129
|
+
| `<break time="Nms"\|"Ns"\|default>` | ✅ inserts silence of the given duration |
|
|
130
|
+
| plain text inside `<speak>` | ✅ synthesized via the selected engine |
|
|
131
|
+
| `<emphasis>`, `<prosody>`, `<phoneme>`, `<say-as>` | ⚠️ stripped with a stderr warning (contained text still synthesized); tracked in [#122](https://github.com/drakulavich/kesha-voice-kit/issues/122) |
|
|
132
|
+
| `<!DOCTYPE>` | ❌ rejected (hardening against XXE) |
|
|
133
|
+
|
|
134
|
+
SSML is opt-in via the explicit `--ssml` flag — inputs that happen to contain `<angle brackets>` aren't misinterpreted as SSML.
|
|
101
135
|
|
|
102
136
|
## What's Inside
|
|
103
137
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drakulavich/kesha-voice-kit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.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.2.0"
|
|
33
33
|
},
|
|
34
34
|
"scripts": {
|
|
35
35
|
"test": "bun test",
|
package/src/cli.ts
CHANGED
|
@@ -32,6 +32,7 @@ interface MainCommandArgs {
|
|
|
32
32
|
_: string[];
|
|
33
33
|
json: boolean;
|
|
34
34
|
verbose: boolean;
|
|
35
|
+
debug: boolean;
|
|
35
36
|
format?: string;
|
|
36
37
|
lang?: string;
|
|
37
38
|
}
|
|
@@ -120,8 +121,23 @@ export const sayCommand = defineCommand({
|
|
|
120
121
|
out: { type: "string", description: "Write WAV to file instead of stdout" },
|
|
121
122
|
rate: { type: "string", description: "Speaking rate 0.5–2.0", default: "1.0" },
|
|
122
123
|
"list-voices": { type: "boolean", description: "List installed voices and exit" },
|
|
124
|
+
ssml: {
|
|
125
|
+
type: "boolean",
|
|
126
|
+
description: "Parse input as SSML (supports <speak>, <break>; strips unknown tags)",
|
|
127
|
+
},
|
|
128
|
+
verbose: {
|
|
129
|
+
type: "boolean",
|
|
130
|
+
description: "Log TTS synthesis time to stderr",
|
|
131
|
+
default: false,
|
|
132
|
+
},
|
|
133
|
+
debug: {
|
|
134
|
+
type: "boolean",
|
|
135
|
+
description: "Trace engine subprocess calls on stderr (or KESHA_DEBUG=1)",
|
|
136
|
+
default: false,
|
|
137
|
+
},
|
|
123
138
|
},
|
|
124
139
|
async run({ args }) {
|
|
140
|
+
if (args.debug) log.debugEnabled = true;
|
|
125
141
|
if (args["list-voices"]) {
|
|
126
142
|
// The engine prints the list directly — just relay its stdout + exit code.
|
|
127
143
|
const { getEngineBinPath } = await import("./engine");
|
|
@@ -143,10 +159,17 @@ export const sayCommand = defineCommand({
|
|
|
143
159
|
lang: typeof args.lang === "string" ? args.lang : undefined,
|
|
144
160
|
out: typeof args.out === "string" ? args.out : undefined,
|
|
145
161
|
rate: args.rate ? Number(args.rate) : undefined,
|
|
162
|
+
ssml: Boolean(args.ssml),
|
|
146
163
|
};
|
|
147
164
|
|
|
148
165
|
try {
|
|
166
|
+
const startedAt = performance.now();
|
|
149
167
|
const wav = await say(opts);
|
|
168
|
+
const ttsTimeMs = Math.round(performance.now() - startedAt);
|
|
169
|
+
if (args.verbose) {
|
|
170
|
+
// stderr — stdout may carry raw WAV bytes when --out is omitted.
|
|
171
|
+
console.error(`TTS time: ${ttsTimeMs}ms`);
|
|
172
|
+
}
|
|
150
173
|
if (!opts.out) {
|
|
151
174
|
process.stdout.write(wav);
|
|
152
175
|
}
|
|
@@ -240,8 +263,14 @@ export const mainCommand = defineCommand({
|
|
|
240
263
|
type: "string",
|
|
241
264
|
description: "Expected language code (ISO 639-1), warn if mismatch",
|
|
242
265
|
},
|
|
266
|
+
debug: {
|
|
267
|
+
type: "boolean",
|
|
268
|
+
description: "Trace engine subprocess calls on stderr (or KESHA_DEBUG=1)",
|
|
269
|
+
default: false,
|
|
270
|
+
},
|
|
243
271
|
},
|
|
244
272
|
async run({ args }: { args: MainCommandArgs }) {
|
|
273
|
+
if (args.debug) log.debugEnabled = true;
|
|
245
274
|
const files = args._;
|
|
246
275
|
|
|
247
276
|
if (files.length === 0) {
|
|
@@ -255,6 +284,7 @@ export const mainCommand = defineCommand({
|
|
|
255
284
|
const wantsLangId = !!(args.lang || args.verbose || args.json || args.format === "transcript" || args.format === "json");
|
|
256
285
|
|
|
257
286
|
for (const file of files) {
|
|
287
|
+
const startedAt = performance.now();
|
|
258
288
|
try {
|
|
259
289
|
// Run audio lang-id and transcription concurrently
|
|
260
290
|
const [audioResult, text] = await Promise.all([
|
|
@@ -293,6 +323,7 @@ export const mainCommand = defineCommand({
|
|
|
293
323
|
lang,
|
|
294
324
|
audioLanguage,
|
|
295
325
|
textLanguage: textLanguage ?? (tinyldLang ? { code: tinyldLang, confidence: 0 } : undefined),
|
|
326
|
+
sttTimeMs: Math.round(performance.now() - startedAt),
|
|
296
327
|
});
|
|
297
328
|
} catch (err: unknown) {
|
|
298
329
|
hasError = true;
|
|
@@ -354,6 +385,8 @@ export type TranscribeResult = {
|
|
|
354
385
|
lang: string;
|
|
355
386
|
audioLanguage?: LangDetectResult;
|
|
356
387
|
textLanguage?: LangDetectResult;
|
|
388
|
+
/** Wall-clock time around the engine subprocess calls for this file, ms. See #139. */
|
|
389
|
+
sttTimeMs?: number;
|
|
357
390
|
};
|
|
358
391
|
|
|
359
392
|
export function formatTextOutput(results: TranscribeResult[]): string {
|
|
@@ -381,6 +414,9 @@ export function formatVerboseOutput(results: TranscribeResult[]): string {
|
|
|
381
414
|
const confStr = textLang.confidence > 0 ? ` (confidence: ${textLang.confidence.toFixed(2)})` : "";
|
|
382
415
|
lines.push(`Text language: ${textLang.code}${confStr}`);
|
|
383
416
|
}
|
|
417
|
+
if (r.sttTimeMs !== undefined) {
|
|
418
|
+
lines.push(`STT time: ${r.sttTimeMs}ms`);
|
|
419
|
+
}
|
|
384
420
|
lines.push("---");
|
|
385
421
|
lines.push(r.text);
|
|
386
422
|
return lines.join("\n");
|
package/src/engine.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { join } from "path";
|
|
2
2
|
import { homedir } from "os";
|
|
3
3
|
import { existsSync } from "fs";
|
|
4
|
+
import { log } from "./log";
|
|
4
5
|
|
|
5
6
|
export interface LangDetectResult {
|
|
6
7
|
code: string;
|
|
@@ -24,6 +25,8 @@ export function isEngineInstalled(): boolean {
|
|
|
24
25
|
|
|
25
26
|
async function runEngine(args: string[]): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
|
26
27
|
const binPath = getEngineBinPath();
|
|
28
|
+
const startedAt = performance.now();
|
|
29
|
+
log.debug(`spawn ${binPath} ${args.join(" ")}`);
|
|
27
30
|
const proc = Bun.spawn([binPath, ...args], {
|
|
28
31
|
stdout: "pipe",
|
|
29
32
|
stderr: "pipe",
|
|
@@ -35,6 +38,7 @@ async function runEngine(args: string[]): Promise<{ stdout: string; stderr: stri
|
|
|
35
38
|
proc.exited,
|
|
36
39
|
]);
|
|
37
40
|
|
|
41
|
+
log.debug(`exit=${exitCode} dt=${Math.round(performance.now() - startedAt)}ms args=${JSON.stringify(args)}`);
|
|
38
42
|
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode };
|
|
39
43
|
}
|
|
40
44
|
|
package/src/log.ts
CHANGED
|
@@ -1,9 +1,26 @@
|
|
|
1
1
|
import pc from "picocolors";
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Debug mode (#148): when `KESHA_DEBUG` is truthy OR the caller has flipped
|
|
5
|
+
* `log.debugEnabled = true` (via `--debug`), `log.debug()` writes structured
|
|
6
|
+
* trace lines to stderr. Otherwise it's a no-op. Stdout is never touched.
|
|
7
|
+
*/
|
|
8
|
+
function envDebug(): boolean {
|
|
9
|
+
const v = process.env.KESHA_DEBUG;
|
|
10
|
+
return !!v && v !== "0" && v.toLowerCase() !== "false";
|
|
11
|
+
}
|
|
12
|
+
|
|
3
13
|
export const log = {
|
|
4
14
|
info: (msg: string) => console.log(msg),
|
|
5
15
|
success: (msg: string) => console.log(pc.green(msg)),
|
|
6
16
|
progress: (msg: string) => console.log(pc.cyan(msg)),
|
|
7
17
|
warn: (msg: string) => console.error(pc.yellow(msg)),
|
|
8
18
|
error: (msg: string) => console.error(pc.red(msg)),
|
|
19
|
+
|
|
20
|
+
debugEnabled: false,
|
|
21
|
+
debug(msg: string): void {
|
|
22
|
+
if (this.debugEnabled || envDebug()) {
|
|
23
|
+
console.error(pc.dim(`[debug] ${msg}`));
|
|
24
|
+
}
|
|
25
|
+
},
|
|
9
26
|
};
|
package/src/say.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getEngineBinPath, isEngineInstalled } from "./engine";
|
|
2
|
+
import { log } from "./log";
|
|
2
3
|
|
|
3
4
|
export interface SayOptions {
|
|
4
5
|
/**
|
|
@@ -15,6 +16,8 @@ export interface SayOptions {
|
|
|
15
16
|
out?: string;
|
|
16
17
|
/** Speaking rate 0.5–2.0. */
|
|
17
18
|
rate?: number;
|
|
19
|
+
/** Parse `text` as SSML (`<speak>…<break time="500ms"/>…</speak>`). See issue #122. */
|
|
20
|
+
ssml?: boolean;
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
/** Build the argv passed to `kesha-engine say` (pure, unit-testable). */
|
|
@@ -24,6 +27,7 @@ export function buildSayArgs(o: SayOptions): string[] {
|
|
|
24
27
|
if (o.lang) args.push("--lang", o.lang);
|
|
25
28
|
if (o.out) args.push("--out", o.out);
|
|
26
29
|
if (o.rate !== undefined && o.rate !== 1.0) args.push("--rate", String(o.rate));
|
|
30
|
+
if (o.ssml) args.push("--ssml");
|
|
27
31
|
if (o.text !== undefined && o.text.length > 0) args.push(o.text);
|
|
28
32
|
return args;
|
|
29
33
|
}
|
|
@@ -52,6 +56,8 @@ export async function say(opts: SayOptions): Promise<Uint8Array> {
|
|
|
52
56
|
);
|
|
53
57
|
}
|
|
54
58
|
const args = buildSayArgs({ ...opts, text: undefined });
|
|
59
|
+
const startedAt = performance.now();
|
|
60
|
+
log.debug(`spawn ${getEngineBinPath()} ${args.join(" ")} (text: ${opts.text?.length ?? 0} chars)`);
|
|
55
61
|
const proc = Bun.spawn([getEngineBinPath(), ...args], {
|
|
56
62
|
stdin: "pipe",
|
|
57
63
|
stdout: "pipe",
|
|
@@ -71,6 +77,7 @@ export async function say(opts: SayOptions): Promise<Uint8Array> {
|
|
|
71
77
|
proc.exited,
|
|
72
78
|
]);
|
|
73
79
|
|
|
80
|
+
log.debug(`exit=${exitCode} dt=${Math.round(performance.now() - startedAt)}ms bytes=${stdoutBuf.byteLength}`);
|
|
74
81
|
if (exitCode !== 0) {
|
|
75
82
|
throw new SayError(
|
|
76
83
|
stderrText.trim() || `kesha-engine say exited ${exitCode}`,
|