@drakulavich/kesha-voice-kit 2.2.0 → 2.3.0-beta.1
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/completions/kesha.fish +1 -1
- package/completions/kesha.zsh +2 -2
- package/man/kesha.1 +1 -1
- package/model-plan.json +3 -0
- package/package.json +10 -11
- package/src/cli/main.ts +14 -5
- package/src/install-hint.ts +3 -1
- package/src/mcp/tools.ts +4 -3
- package/src/progress.ts +49 -18
- package/src/synth.ts +37 -1
package/completions/kesha.fish
CHANGED
|
@@ -26,7 +26,7 @@ complete -c kesha -n '__fish_use_subcommand' -l format -r -d 'Output format: tra
|
|
|
26
26
|
complete -c kesha -n '__fish_use_subcommand' -l lang -r -d 'Expected language code, e.g. en or en-us (see docs/languages.md); warn if mismatch'
|
|
27
27
|
complete -c kesha -n '__fish_use_subcommand' -l debug -d 'Trace engine subprocess calls on stderr (or KESHA_DEBUG=1)'
|
|
28
28
|
complete -c kesha -n '__fish_use_subcommand' -l vad -d 'Force Silero VAD preprocessing (kesha install --vad first). Without this, VAD auto-engages on audio ≥ 120s.'
|
|
29
|
-
complete -c kesha -n '__fish_use_subcommand' -l no-vad -d '
|
|
29
|
+
complete -c kesha -n '__fish_use_subcommand' -l no-vad -d 'Skip VAD and transcribe the whole file in one pass; audio measured at 24 minutes or more is refused. Incompatible with --speakers'
|
|
30
30
|
complete -c kesha -n '__fish_use_subcommand' -l quiet -d 'Suppress progress output; print only results and errors'
|
|
31
31
|
complete -c kesha -n '__fish_use_subcommand' -l no-color -d 'Disable ANSI colors (also via NO_COLOR=1; auto-off when CI=true)'
|
|
32
32
|
complete -c kesha -n '__fish_seen_subcommand_from completions' -l help -d 'Show help'
|
package/completions/kesha.zsh
CHANGED
|
@@ -35,7 +35,7 @@ _kesha() {
|
|
|
35
35
|
'--lang=[Expected language code, e.g. en or en-us (see docs/languages.md); warn if mismatch]:lang:' \
|
|
36
36
|
'--debug[Trace engine subprocess calls on stderr (or KESHA_DEBUG=1)]' \
|
|
37
37
|
'--vad[Force Silero VAD preprocessing (kesha install --vad first). Without this, VAD auto-engages on audio ≥ 120s.]' \
|
|
38
|
-
'--no-vad[
|
|
38
|
+
'--no-vad[Skip VAD and transcribe the whole file in one pass; audio measured at 24 minutes or more is refused. Incompatible with --speakers]' \
|
|
39
39
|
'--quiet[Suppress progress output; print only results and errors]' \
|
|
40
40
|
'--no-color[Disable ANSI colors (also via NO_COLOR=1; auto-off when CI=true)]'
|
|
41
41
|
else
|
|
@@ -154,7 +154,7 @@ _kesha() {
|
|
|
154
154
|
'--lang=[Expected language code, e.g. en or en-us (see docs/languages.md); warn if mismatch]:lang:' \
|
|
155
155
|
'--debug[Trace engine subprocess calls on stderr (or KESHA_DEBUG=1)]' \
|
|
156
156
|
'--vad[Force Silero VAD preprocessing (kesha install --vad first). Without this, VAD auto-engages on audio ≥ 120s.]' \
|
|
157
|
-
'--no-vad[
|
|
157
|
+
'--no-vad[Skip VAD and transcribe the whole file in one pass; audio measured at 24 minutes or more is refused. Incompatible with --speakers]' \
|
|
158
158
|
'--quiet[Suppress progress output; print only results and errors]' \
|
|
159
159
|
'--no-color[Disable ANSI colors (also via NO_COLOR=1; auto-off when CI=true)]' \
|
|
160
160
|
'*:audio file:_files'
|
package/man/kesha.1
CHANGED
|
@@ -82,7 +82,7 @@ Trace engine subprocess calls on stderr (or KESHA_DEBUG=1)
|
|
|
82
82
|
Force Silero VAD preprocessing (kesha install \-\-vad first). Without this, VAD auto\-engages on audio ≥ 120s.
|
|
83
83
|
.TP
|
|
84
84
|
.B \-\-no\-vad
|
|
85
|
-
|
|
85
|
+
Skip VAD and transcribe the whole file in one pass; audio measured at 24 minutes or more is refused. Incompatible with \-\-speakers
|
|
86
86
|
.TP
|
|
87
87
|
.B \-\-quiet
|
|
88
88
|
Suppress progress output; print only results and errors
|
package/model-plan.json
CHANGED
|
@@ -161,6 +161,9 @@
|
|
|
161
161
|
{ "relPath": "g2pw/g2pw.mlmodelc/weights/weight.bin", "sizeBytes": 159294962 }
|
|
162
162
|
],
|
|
163
163
|
"aneZhG2p": [
|
|
164
|
+
{ "relPath": "g2p/jieba_hmm_emit.bin", "sizeBytes": 292640 },
|
|
165
|
+
{ "relPath": "g2p/jieba_hmm_start.bin", "sizeBytes": 16 },
|
|
166
|
+
{ "relPath": "g2p/jieba_hmm_trans.bin", "sizeBytes": 64 },
|
|
164
167
|
{ "relPath": "g2p/pinyin_phrases.bin", "sizeBytes": 9552083 },
|
|
165
168
|
{ "relPath": "g2p/pinyin_single.bin", "sizeBytes": 480169 }
|
|
166
169
|
],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drakulavich/kesha-voice-kit",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0-beta.1",
|
|
4
4
|
"description": "Kesha listens and talks back: local speech to text and text to speech for agents and scripts, fastest on Apple Silicon.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -50,8 +50,8 @@
|
|
|
50
50
|
"coverage:rust": "mkdir -p coverage/rust && cd rust && cargo llvm-cov nextest --features tts --lcov --output-path ../coverage/rust/lcov.info",
|
|
51
51
|
"coverage:check:rust": "bun .github/scripts/check-coverage.ts --preset=rust --lcov=coverage/rust/lcov.info --summary=coverage/rust/summary.md",
|
|
52
52
|
"generate:shell-artifacts": "bun scripts/generate-shell-artifacts.ts",
|
|
53
|
-
"lint": "
|
|
54
|
-
"check": "bun run lint && bun run check:versions && bun run check:recipes && bun run test:cli-fast",
|
|
53
|
+
"lint": "bun check",
|
|
54
|
+
"check:fast": "bun run lint && bun run check:versions && bun run check:recipes && bun run test:cli-fast",
|
|
55
55
|
"check:workflows": "bun .github/scripts/check-workflows.ts",
|
|
56
56
|
"check:recipes": "bun .github/scripts/check-recipes.ts",
|
|
57
57
|
"check:specs": "openspec validate --specs --strict",
|
|
@@ -89,7 +89,6 @@
|
|
|
89
89
|
"@fission-ai/openspec": "1.14.1",
|
|
90
90
|
"@types/bun": "latest",
|
|
91
91
|
"fast-xml-parser": "^5.8.0",
|
|
92
|
-
"typescript": "^7.0.2",
|
|
93
92
|
"yaml": "^2.9.0"
|
|
94
93
|
},
|
|
95
94
|
"dependencies": {
|
|
@@ -110,18 +109,18 @@
|
|
|
110
109
|
},
|
|
111
110
|
"kesha": {
|
|
112
111
|
"engine": {
|
|
113
|
-
"version": "2.
|
|
112
|
+
"version": "2.3.0-beta.1",
|
|
114
113
|
"sha256": {
|
|
115
|
-
"kesha-engine-darwin-arm64": "
|
|
116
|
-
"kesha-engine-linux-x64": "
|
|
117
|
-
"kesha-engine-windows-x64.exe": "
|
|
114
|
+
"kesha-engine-darwin-arm64": "dade5c6c2d8fc838a38c1381915ee9e3a1d2840f20c87b6cabcdd3f2dcec3c9e",
|
|
115
|
+
"kesha-engine-linux-x64": "2b6e604cb24fca040b60136df3e4e29e3005959aea0a6cd7d3fb80930e9f3380",
|
|
116
|
+
"kesha-engine-windows-x64.exe": "269e13d10852bc01fdce2bec880bb9831bdaf19c4cb8e7a2d0a09a84c2980dc7",
|
|
118
117
|
"say-avspeech-darwin-arm64": "3a7e8b8fac92ce4ce0c34f736098a685dcb745f741db8303e1721e320ac73a98",
|
|
119
118
|
"kesha-textlang-darwin-arm64": "e45e2822248b14dd72aa79e2def8fa6b9c07c97423f11c841508769225d72da4"
|
|
120
119
|
},
|
|
121
120
|
"size": {
|
|
122
|
-
"kesha-engine-darwin-arm64":
|
|
123
|
-
"kesha-engine-linux-x64":
|
|
124
|
-
"kesha-engine-windows-x64.exe":
|
|
121
|
+
"kesha-engine-darwin-arm64": 73639696,
|
|
122
|
+
"kesha-engine-linux-x64": 67403816,
|
|
123
|
+
"kesha-engine-windows-x64.exe": 66584064,
|
|
125
124
|
"say-avspeech-darwin-arm64": 82992,
|
|
126
125
|
"kesha-textlang-darwin-arm64": 57648
|
|
127
126
|
}
|
package/src/cli/main.ts
CHANGED
|
@@ -295,6 +295,14 @@ type ProcessFileSuccess = { ok: true; result: TranscribeResult };
|
|
|
295
295
|
type ProcessFileFailure = { ok: false; error: TranscribeErrorRecord; exitCode: number };
|
|
296
296
|
|
|
297
297
|
/** An argument the CLI itself rejected exits by its code (2); every runtime failure keeps the batch's operational 1 (S9-F1). */
|
|
298
|
+
/** `kesha <dir>/*.ogg`, pasteable: a name starting with `-` would parse as a flag, and only Windows separates with `\`. */
|
|
299
|
+
export function folderGlobHint(dir: string): string {
|
|
300
|
+
const trimmed = dir.replace(process.platform === "win32" ? /[\\/]+$/ : /\/+$/, "");
|
|
301
|
+
const path = trimmed.startsWith("-") ? `./${trimmed}` : trimmed;
|
|
302
|
+
const quoted = /^[\w./-]+$/.test(path) ? path : `'${path.replaceAll("'", `'\\''`)}'`;
|
|
303
|
+
return `kesha ${quoted}/*.ogg`;
|
|
304
|
+
}
|
|
305
|
+
|
|
298
306
|
function failureExitCode(err: unknown): number {
|
|
299
307
|
const usage = err instanceof KeshaError && err.origin === "cli" && err.code === "E_INVALID_ARG";
|
|
300
308
|
return usage ? exitCodeFor(err) : 1;
|
|
@@ -320,7 +328,9 @@ async function processFile(
|
|
|
320
328
|
}
|
|
321
329
|
|
|
322
330
|
if (isDirectoryPath(file)) {
|
|
323
|
-
const err = new KeshaError("E_INVALID_ARG", "is a directory (expected an audio file)"
|
|
331
|
+
const err = new KeshaError("E_INVALID_ARG", "is a directory (expected an audio file)", {
|
|
332
|
+
hint: `pass the audio files in it instead, e.g. ${folderGlobHint(file)}`,
|
|
333
|
+
});
|
|
324
334
|
stats.recordError("input", err, err.code);
|
|
325
335
|
diagnosticLog.event("input.invalid", {
|
|
326
336
|
command: "transcribe",
|
|
@@ -346,9 +356,8 @@ async function processFile(
|
|
|
346
356
|
// so the engine's progress has to land while it happens, not once the run is over.
|
|
347
357
|
const showProgressLine = (line: string) => {
|
|
348
358
|
if (log.quietEnabled) return;
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
else write();
|
|
359
|
+
if (progress) progress.status(line);
|
|
360
|
+
else process.stderr.write(`${line}\n`);
|
|
352
361
|
};
|
|
353
362
|
try {
|
|
354
363
|
await validateTranscribeRequest({ vad: vadMode, timestamps, speakers, itn });
|
|
@@ -451,7 +460,7 @@ export const MAIN_VAD_ARGS = {
|
|
|
451
460
|
"no-vad": {
|
|
452
461
|
type: "boolean",
|
|
453
462
|
description:
|
|
454
|
-
"
|
|
463
|
+
"Skip VAD and transcribe the whole file in one pass; audio measured at 24 minutes or more is refused. Incompatible with --speakers",
|
|
455
464
|
default: false,
|
|
456
465
|
},
|
|
457
466
|
} as const;
|
package/src/install-hint.ts
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
// `--no-cache`) — they preselect modules in the interactive wizard rather than
|
|
8
8
|
// being install-only flags. Source of truth: `src/cli/init.ts::initCommand.args`.
|
|
9
9
|
export function installHint(...flags: string[]): string {
|
|
10
|
-
|
|
10
|
+
// `kesha init` refuses unless stdin and stdout are terminals (#1373), so a piped run gets `install` (#1380).
|
|
11
|
+
const interactive = process.stdin.isTTY && process.stdout.isTTY && process.stderr.isTTY;
|
|
12
|
+
const verb = interactive ? "kesha init" : "kesha install";
|
|
11
13
|
return [verb, ...flags].join(" ");
|
|
12
14
|
}
|
package/src/mcp/tools.ts
CHANGED
|
@@ -59,10 +59,11 @@ export function registerTools(server: McpServer): void {
|
|
|
59
59
|
},
|
|
60
60
|
async ({ text, voice, rate, format }, extra) => {
|
|
61
61
|
if (extra.signal?.aborted) {
|
|
62
|
-
return { isError: true, content: [{ type: "text" as const, text: "request cancelled" }] };
|
|
62
|
+
return { isError: true, content: [{ type: "text" as const, text: errorMessage(new KeshaError("E_INTERRUPTED", "request cancelled")) }] };
|
|
63
63
|
}
|
|
64
64
|
if (rate !== undefined && !(rate >= 0.5 && rate <= 2.0)) {
|
|
65
|
-
|
|
65
|
+
const err = new KeshaError("E_INVALID_ARG", `rate ${rate} out of range (0.5-2.0)`);
|
|
66
|
+
return { isError: true, content: [{ type: "text" as const, text: errorMessage(err) }] };
|
|
66
67
|
}
|
|
67
68
|
const fmt: SayFormat = format ?? "wav";
|
|
68
69
|
const outPath = allocAudioPath(fmt);
|
|
@@ -121,7 +122,7 @@ export function registerTools(server: McpServer): void {
|
|
|
121
122
|
},
|
|
122
123
|
async ({ path, timestamps }, extra) => {
|
|
123
124
|
if (extra.signal?.aborted) {
|
|
124
|
-
return { isError: true, content: [{ type: "text" as const, text: "request cancelled" }] };
|
|
125
|
+
return { isError: true, content: [{ type: "text" as const, text: errorMessage(new KeshaError("E_INTERRUPTED", "request cancelled")) }] };
|
|
125
126
|
}
|
|
126
127
|
if (!existsSync(path)) {
|
|
127
128
|
const hint = isAbsolute(path)
|
package/src/progress.ts
CHANGED
|
@@ -36,12 +36,28 @@ export function estimatePercent(elapsedMs: number, estimatedTotalMs: number): nu
|
|
|
36
36
|
return Math.max(1, Math.min(99, Math.floor((elapsedMs / targetMs) * 99)));
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
// Whatever was painted on the row before, by this bar or another, would show past a shorter frame (#1440).
|
|
40
|
+
const ERASE_TO_EOL = "\x1b[K";
|
|
41
|
+
|
|
42
|
+
/** Cuts `text` to `max` terminal columns, ending in `…` when cut; wide (CJK) characters count as two. */
|
|
43
|
+
function fitWidth(text: string, max: number): string {
|
|
44
|
+
if (Bun.stringWidth(text) <= max) return text;
|
|
45
|
+
if (max <= 0) return "";
|
|
46
|
+
let out = "";
|
|
47
|
+
for (const { segment } of new Intl.Segmenter().segment(text)) {
|
|
48
|
+
if (Bun.stringWidth(`${out}${segment}…`) > max) break;
|
|
49
|
+
out += segment;
|
|
50
|
+
}
|
|
51
|
+
return `${out}…`;
|
|
52
|
+
}
|
|
53
|
+
|
|
39
54
|
export function createPercentProgress(
|
|
40
55
|
label: string,
|
|
41
56
|
options: { estimatedTotalMs?: number; intervalMs?: number } = {},
|
|
42
57
|
): {
|
|
43
58
|
finish(finalLabel: string): void;
|
|
44
59
|
interrupt(writeLine: () => void): void;
|
|
60
|
+
status(line: string): void;
|
|
45
61
|
stop(): void;
|
|
46
62
|
} {
|
|
47
63
|
const isTTY = process.stderr.isTTY;
|
|
@@ -55,6 +71,9 @@ export function createPercentProgress(
|
|
|
55
71
|
interrupt(writeLine: () => void) {
|
|
56
72
|
writeLine();
|
|
57
73
|
},
|
|
74
|
+
status(line: string) {
|
|
75
|
+
process.stderr.write(`${line}\n`);
|
|
76
|
+
},
|
|
58
77
|
stop() {},
|
|
59
78
|
};
|
|
60
79
|
}
|
|
@@ -62,18 +81,23 @@ export function createPercentProgress(
|
|
|
62
81
|
const intervalMs = options.intervalMs ?? 250;
|
|
63
82
|
const estimatedTotalMs = options.estimatedTotalMs ?? DEFAULT_ESTIMATED_PROGRESS_MS;
|
|
64
83
|
const startedAt = performance.now();
|
|
65
|
-
let
|
|
84
|
+
let painted = false;
|
|
66
85
|
let timer: Timer | undefined;
|
|
67
86
|
let stopped = false;
|
|
68
87
|
let firstRender = true;
|
|
88
|
+
let statusText = "";
|
|
69
89
|
|
|
70
90
|
const render = () => {
|
|
71
91
|
if (stopped) return;
|
|
72
92
|
const percent = firstRender ? 0 : estimatePercent(performance.now() - startedAt, estimatedTotalMs);
|
|
73
93
|
firstRender = false;
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
process.stderr.
|
|
94
|
+
const rest = formatPercentProgress("", percent) + (statusText ? ` ${statusText}` : "");
|
|
95
|
+
// A row wider than the terminal wraps, and `\r` then repaints only its last part; the file name gives way first.
|
|
96
|
+
const columns = process.stderr.columns;
|
|
97
|
+
const name = columns ? fitWidth(label, columns - 1 - Bun.stringWidth(rest)) : label;
|
|
98
|
+
const line = columns ? fitWidth(name + rest, columns - 1) : name + rest;
|
|
99
|
+
painted = true;
|
|
100
|
+
process.stderr.write(`\r${line}${ERASE_TO_EOL}`);
|
|
77
101
|
};
|
|
78
102
|
|
|
79
103
|
const clearLine = (stopTimer: boolean) => {
|
|
@@ -82,9 +106,9 @@ export function createPercentProgress(
|
|
|
82
106
|
clearInterval(timer);
|
|
83
107
|
timer = undefined;
|
|
84
108
|
}
|
|
85
|
-
if (
|
|
86
|
-
process.stderr.write(`\r${
|
|
87
|
-
|
|
109
|
+
if (painted) {
|
|
110
|
+
process.stderr.write(`\r${ERASE_TO_EOL}`);
|
|
111
|
+
painted = false;
|
|
88
112
|
}
|
|
89
113
|
};
|
|
90
114
|
|
|
@@ -106,6 +130,14 @@ export function createPercentProgress(
|
|
|
106
130
|
render();
|
|
107
131
|
timer = setInterval(render, intervalMs);
|
|
108
132
|
},
|
|
133
|
+
status(line: string) {
|
|
134
|
+
if (stopped) {
|
|
135
|
+
process.stderr.write(`${line}\n`);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
statusText = line;
|
|
139
|
+
render();
|
|
140
|
+
},
|
|
109
141
|
stop() {
|
|
110
142
|
clearLine(true);
|
|
111
143
|
},
|
|
@@ -119,11 +151,11 @@ export function createPercentProgress(
|
|
|
119
151
|
*/
|
|
120
152
|
export function createLiveStatus(): { update(line: string): void; clear(): void } {
|
|
121
153
|
const isTTY = process.stderr.isTTY && !log.quietEnabled;
|
|
122
|
-
let painted =
|
|
154
|
+
let painted = false;
|
|
123
155
|
const erase = () => {
|
|
124
|
-
if (painted
|
|
125
|
-
process.stderr.write(`\r${
|
|
126
|
-
painted =
|
|
156
|
+
if (!painted) return;
|
|
157
|
+
process.stderr.write(`\r${ERASE_TO_EOL}`);
|
|
158
|
+
painted = false;
|
|
127
159
|
};
|
|
128
160
|
// Ctrl-C exits through `process.exit` without unwinding the spawn's `finally` (src/process-tree.ts),
|
|
129
161
|
// so without this the painted row survives into the shell prompt.
|
|
@@ -131,13 +163,12 @@ export function createLiveStatus(): { update(line: string): void; clear(): void
|
|
|
131
163
|
return {
|
|
132
164
|
update(line: string) {
|
|
133
165
|
if (!isTTY) return;
|
|
134
|
-
if (painted
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
painted = line.length;
|
|
166
|
+
if (!painted) process.once("exit", onExit);
|
|
167
|
+
process.stderr.write(`\r${line}${ERASE_TO_EOL}`);
|
|
168
|
+
painted = true;
|
|
138
169
|
},
|
|
139
170
|
clear() {
|
|
140
|
-
if (painted
|
|
171
|
+
if (!painted) return;
|
|
141
172
|
erase();
|
|
142
173
|
process.removeListener("exit", onExit);
|
|
143
174
|
},
|
|
@@ -303,11 +334,11 @@ export function createProgressBar(label: string, totalBytes: number): {
|
|
|
303
334
|
if (pct === lastPct) return;
|
|
304
335
|
lastPct = pct;
|
|
305
336
|
const line = formatProgressBar(label, current, totalBytes);
|
|
306
|
-
process.stderr.write(`\r${line}`);
|
|
337
|
+
process.stderr.write(`\r${line}${ERASE_TO_EOL}`);
|
|
307
338
|
},
|
|
308
339
|
finish() {
|
|
309
340
|
const line = formatProgressBar(label, totalBytes, totalBytes);
|
|
310
|
-
process.stderr.write(`\r${line}\n`);
|
|
341
|
+
process.stderr.write(`\r${line}${ERASE_TO_EOL}\n`);
|
|
311
342
|
},
|
|
312
343
|
};
|
|
313
344
|
}
|
package/src/synth.ts
CHANGED
|
@@ -102,6 +102,42 @@ export function engineCrashMessage(
|
|
|
102
102
|
return `${base}.`;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
// ©, ® and ™ stay, emoji presentation (U+FE0F) or not: read aloud they still mean something.
|
|
106
|
+
const KEPT_SYMBOL = /([©®™])️/g;
|
|
107
|
+
const EMOJI = /\p{RGI_Emoji}|[\p{Extended_Pictographic}--[©®™]]/gv;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Emoji are decoration in a reply; a voice would read out their Unicode names (#1388).
|
|
111
|
+
* Emoji-only text, SSML tags aside, goes through unchanged, so the engine still refuses it as E_SCRIPT_UNSUPPORTED.
|
|
112
|
+
*/
|
|
113
|
+
export function dropEmoji(text: string, ssml = false): string {
|
|
114
|
+
const spoken = (ssml ? decodeCharRefs(text) : text).replace(KEPT_SYMBOL, "$1").replace(EMOJI, "");
|
|
115
|
+
const speech = ssml ? spoken.replace(CDATA, "$1").replace(/<[^>]*>/g, "") : spoken;
|
|
116
|
+
return speech.trim().length > 0 ? spoken : text;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const CDATA = /<!\[CDATA\[([\s\S]*?)\]\]>/g;
|
|
120
|
+
const NUMERIC_REF = /&#(?:x([0-9a-f]+)|(\d+));/gi;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The engine decodes SSML character references, so `😊` is an emoji too. Decoding them first lets the
|
|
124
|
+
* emoji filter see sequences spelled across references; CDATA keeps its text literal, and markup characters stay escaped.
|
|
125
|
+
*/
|
|
126
|
+
function decodeCharRefs(ssml: string): string {
|
|
127
|
+
return ssml
|
|
128
|
+
.split(/(<!\[CDATA\[[\s\S]*?\]\]>)/)
|
|
129
|
+
.map((part) =>
|
|
130
|
+
part.startsWith("<![CDATA[")
|
|
131
|
+
? part
|
|
132
|
+
: part.replace(NUMERIC_REF, (ref, hex?: string, dec?: string) => {
|
|
133
|
+
const cp = hex !== undefined ? parseInt(hex, 16) : Number(dec);
|
|
134
|
+
const plain = cp >= 0x20 && cp <= 0x10ffff && !(cp >= 0xd800 && cp <= 0xdfff) && !`<>&'"`.includes(String.fromCodePoint(cp));
|
|
135
|
+
return plain ? String.fromCodePoint(cp) : ref;
|
|
136
|
+
}),
|
|
137
|
+
)
|
|
138
|
+
.join("");
|
|
139
|
+
}
|
|
140
|
+
|
|
105
141
|
/**
|
|
106
142
|
* The text contract both doors enforce, before anything that costs a subprocess.
|
|
107
143
|
* Throws `KeshaError`; the CLI runs it before voice routing, `say()` for programmatic callers.
|
|
@@ -133,7 +169,7 @@ export async function say(opts: SayOptions): Promise<Uint8Array> {
|
|
|
133
169
|
for (const warning of warnings) log.warn(warning);
|
|
134
170
|
const { exitCode, signalCode, aborted, stdout, events } = await runEngineProcess(getEngineBinPath(), args, {
|
|
135
171
|
stdio: ["pipe", "pipe", "pipe"],
|
|
136
|
-
stdin: text,
|
|
172
|
+
stdin: dropEmoji(text, opts.ssml),
|
|
137
173
|
readStdout: (stream) => new Response(stream).arrayBuffer(),
|
|
138
174
|
signal: opts.signal,
|
|
139
175
|
});
|