mac-voice-mcp 0.3.1 → 0.4.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/CHANGELOG.md +22 -1
- package/README.md +31 -5
- package/dist/audio.js +110 -2
- package/dist/config.js +15 -0
- package/dist/doctor.js +185 -0
- package/dist/index.js +37 -5
- package/dist/kokoro-text.js +105 -0
- package/dist/kokoro-worker.js +103 -0
- package/dist/kokoro.js +449 -0
- package/dist/pcm.js +49 -0
- package/dist/proc.js +1 -0
- package/dist/server.js +9 -2
- package/dist/setup.js +58 -13
- package/dist/texts.js +2 -0
- package/dist/voice.js +21 -7
- package/dist/wer.js +41 -0
- package/package.json +3 -1
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text preparation for the Kokoro voice (pure, unit-tested).
|
|
3
|
+
*
|
|
4
|
+
* Kokoro reads text through espeak-ng, which trips over some developer jargon, silently stops at
|
|
5
|
+
* newlines, and truncates anything past ~510 tokens. So before speaking we fix the known
|
|
6
|
+
* mispronunciations and cut the text into sentence-sized chunks that can be generated one at a
|
|
7
|
+
* time — the first chunk starts playing while the next is being generated.
|
|
8
|
+
*/
|
|
9
|
+
/** Words and patterns espeak gets wrong, with what to say instead. Applied in order. */
|
|
10
|
+
export const PRONUNCIATIONS = [
|
|
11
|
+
[/\bJSON\b/g, "jay-son"],
|
|
12
|
+
[/\bYAML\b/g, "yammel"],
|
|
13
|
+
[/\bSQLite\b/gi, "S Q lite"],
|
|
14
|
+
[/\bRAM\b/g, "ram"],
|
|
15
|
+
[/\bGIFs?\b/g, (m) => (m.endsWith("s") ? "gifs" : "gif")],
|
|
16
|
+
// file.ext → "file dot ext", spelling short extensions: index.ts → "index dot T S"
|
|
17
|
+
[/\b([A-Za-z][\w-]*)\.(ts|js|tsx|jsx|py|rb|go|rs|md|sh|css|html|json|yml|yaml|toml)\b/g, "$1 dot $2"],
|
|
18
|
+
// version numbers: 1.2.3 → "1 point 2 point 3" (espeak reads them as decimals)
|
|
19
|
+
[/\bv?(\d+)\.(\d+)\.(\d+)\b/g, "$1 point $2 point $3"],
|
|
20
|
+
];
|
|
21
|
+
const SPELL_OUT = new Set(["ts", "js", "tsx", "jsx", "py", "rb", "rs", "md", "sh", "css", "yml"]);
|
|
22
|
+
const SAY_AS = { json: "jay-son", yaml: "yammel" };
|
|
23
|
+
export function fixPronunciation(text) {
|
|
24
|
+
let out = text;
|
|
25
|
+
for (const [pattern, replacement] of PRONUNCIATIONS)
|
|
26
|
+
out = out.replace(pattern, replacement);
|
|
27
|
+
// file extensions: spell the short ones letter by letter ("dot ts" → "dot T S")
|
|
28
|
+
return out.replace(/\bdot (\w+)\b/g, (m, ext) => {
|
|
29
|
+
const lower = ext.toLowerCase();
|
|
30
|
+
if (SAY_AS[lower])
|
|
31
|
+
return `dot ${SAY_AS[lower]}`;
|
|
32
|
+
return SPELL_OUT.has(lower) ? `dot ${ext.toUpperCase().split("").join(" ")}` : m;
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
/** Longest chunk handed to Kokoro at once, in characters (well under its ~510-token limit). */
|
|
36
|
+
export const MAX_CHUNK_CHARS = 250;
|
|
37
|
+
/**
|
|
38
|
+
* The first chunk is kept short: nothing plays until it's generated, so its length is the delay
|
|
39
|
+
* before the first sound. Later chunks are generated while earlier ones play.
|
|
40
|
+
*/
|
|
41
|
+
export const FIRST_CHUNK_CHARS = 100;
|
|
42
|
+
/** Short sentences are merged up to this size: very short inputs sound worse. */
|
|
43
|
+
const MERGE_UP_TO = 120;
|
|
44
|
+
/** The first chunk only absorbs the next sentence while it's this short (e.g. "Done."). */
|
|
45
|
+
const FIRST_MERGE_UP_TO = 30;
|
|
46
|
+
/** Cut one sentence into pieces of at most `limit` characters: at clause boundaries, else between words. */
|
|
47
|
+
function splitLong(sentence, limit) {
|
|
48
|
+
const pieces = [];
|
|
49
|
+
let current = "";
|
|
50
|
+
for (const clause of sentence.split(/(?<=[,;:—–])\s+/)) {
|
|
51
|
+
for (const word of clause.split(" ")) {
|
|
52
|
+
if (current && current.length + 1 + word.length > limit) {
|
|
53
|
+
pieces.push(current);
|
|
54
|
+
current = "";
|
|
55
|
+
}
|
|
56
|
+
current = current ? `${current} ${word}` : word;
|
|
57
|
+
}
|
|
58
|
+
if (current.length >= limit * 0.6) {
|
|
59
|
+
pieces.push(current);
|
|
60
|
+
current = "";
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (current)
|
|
64
|
+
pieces.push(current);
|
|
65
|
+
return pieces;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Split text into speakable chunks: sentences, merged when short, split at commas/words when long.
|
|
69
|
+
* The first chunk is short, so speech starts quickly.
|
|
70
|
+
*/
|
|
71
|
+
export function chunkForSpeech(text, maxChars = MAX_CHUNK_CHARS) {
|
|
72
|
+
const clean = text
|
|
73
|
+
.replace(/\s*\n+\s*/g, ". ") // Kokoro stops at a newline
|
|
74
|
+
.replace(/([.!?:;,])\s*\.(?=\s|$)/g, "$1") // "Done:\n" → "Done:" not "Done:."
|
|
75
|
+
.replace(/\.(\s*\.)+/g, ".")
|
|
76
|
+
.replace(/\s+/g, " ")
|
|
77
|
+
.trim();
|
|
78
|
+
if (!/[\p{L}\p{N}]/u.test(clean))
|
|
79
|
+
return []; // nothing to say (blank, or only punctuation)
|
|
80
|
+
const sentences = clean.match(/[^.!?]+(?:[.!?]+(?=\s|$)|$)/g)?.map((s) => s.trim()).filter(Boolean) ?? [clean];
|
|
81
|
+
const pieces = [];
|
|
82
|
+
for (const sentence of sentences) {
|
|
83
|
+
const limit = pieces.length === 0 ? Math.min(FIRST_CHUNK_CHARS, maxChars) : maxChars;
|
|
84
|
+
if (sentence.length <= limit)
|
|
85
|
+
pieces.push(sentence);
|
|
86
|
+
else
|
|
87
|
+
pieces.push(...splitLong(sentence, limit));
|
|
88
|
+
}
|
|
89
|
+
// Merge neighbours while short, so tiny fragments don't get their own odd-sounding chunk.
|
|
90
|
+
const chunks = [];
|
|
91
|
+
for (const p of pieces) {
|
|
92
|
+
const last = chunks[chunks.length - 1];
|
|
93
|
+
const mergeUpTo = chunks.length === 1 ? FIRST_MERGE_UP_TO : MERGE_UP_TO;
|
|
94
|
+
const limit = chunks.length === 1 ? FIRST_CHUNK_CHARS : maxChars;
|
|
95
|
+
if (last !== undefined && last.length < mergeUpTo && last.length + 1 + p.length <= limit)
|
|
96
|
+
chunks[chunks.length - 1] = `${last} ${p}`;
|
|
97
|
+
else
|
|
98
|
+
chunks.push(p);
|
|
99
|
+
}
|
|
100
|
+
return chunks;
|
|
101
|
+
}
|
|
102
|
+
/** The text Kokoro should speak, as chunks. */
|
|
103
|
+
export function prepareForKokoro(text) {
|
|
104
|
+
return chunkForSpeech(fixPronunciation(text));
|
|
105
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Kokoro voice, in its own process.
|
|
3
|
+
*
|
|
4
|
+
* Kokoro (kokoro-js + onnxruntime) is a ~330 MB model that takes seconds to load and holds
|
|
5
|
+
* hundreds of MB of memory, so it runs here, kept warm between turns, instead of inside the MCP
|
|
6
|
+
* server. If it crashes or leaks, the server just starts a fresh one.
|
|
7
|
+
*
|
|
8
|
+
* Protocol: one JSON object per line.
|
|
9
|
+
* in (stdin): {type:"speak", id, chunks:[text…]} {type:"cancel", id}
|
|
10
|
+
* out (fd 3): {type:"ready", voice, voices, loadMs}
|
|
11
|
+
* {type:"audio", id, seq, pcm} pcm = base64 16-bit little-endian, 24 kHz mono
|
|
12
|
+
* {type:"done", id, cancelled, rssMb}
|
|
13
|
+
* {type:"error", id?, message, fatal?}
|
|
14
|
+
* stdout is not used for messages: libraries print to it (e.g. a voice table), so it's left to logs.
|
|
15
|
+
*
|
|
16
|
+
* Settings come from the environment (set by kokoro.ts): KOKORO_DIR, KOKORO_DTYPE, KOKORO_VOICE,
|
|
17
|
+
* KOKORO_SPEED, KOKORO_ALLOW_DOWNLOAD ("1" only during an install the user agreed to).
|
|
18
|
+
*/
|
|
19
|
+
import { createWriteStream } from "node:fs";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import { createInterface } from "node:readline";
|
|
22
|
+
import { pathToFileURL } from "node:url";
|
|
23
|
+
import { floatToPcm16 } from "./pcm.js";
|
|
24
|
+
const KOKORO_MODEL_ID = "onnx-community/Kokoro-82M-v1.0-ONNX";
|
|
25
|
+
const out = createWriteStream("", { fd: 3 });
|
|
26
|
+
out.on("error", () => process.exit(0)); // the server went away
|
|
27
|
+
const send = (msg) => out.write(JSON.stringify(msg) + "\n");
|
|
28
|
+
async function main() {
|
|
29
|
+
const dir = process.env.KOKORO_DIR;
|
|
30
|
+
if (!dir)
|
|
31
|
+
throw new Error("KOKORO_DIR is not set");
|
|
32
|
+
const dtype = process.env.KOKORO_DTYPE === "q8" ? "q8" : "fp32";
|
|
33
|
+
const speed = Number(process.env.KOKORO_SPEED) > 0 ? Number(process.env.KOKORO_SPEED) : 1;
|
|
34
|
+
const t0 = Date.now();
|
|
35
|
+
// entry.mjs re-exports KokoroTTS and transformers' env from the same install, so they share state.
|
|
36
|
+
const mod = (await import(pathToFileURL(path.join(dir, "entry.mjs")).href));
|
|
37
|
+
mod.env.cacheDir = path.join(dir, "models");
|
|
38
|
+
mod.env.allowRemoteModels = process.env.KOKORO_ALLOW_DOWNLOAD === "1";
|
|
39
|
+
let lastProgress = -1;
|
|
40
|
+
const tts = await mod.KokoroTTS.from_pretrained(KOKORO_MODEL_ID, {
|
|
41
|
+
dtype,
|
|
42
|
+
device: "cpu",
|
|
43
|
+
progress_callback: (p) => {
|
|
44
|
+
if (p.status !== "progress" || !p.file?.endsWith(".onnx"))
|
|
45
|
+
return;
|
|
46
|
+
const pct = Math.floor((p.progress ?? 0) / 10) * 10;
|
|
47
|
+
if (pct !== lastProgress) {
|
|
48
|
+
lastProgress = pct;
|
|
49
|
+
send({ type: "progress", message: `downloading the Kokoro model: ${pct}%` });
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
const voices = Object.keys(tts.voices);
|
|
54
|
+
const wanted = process.env.KOKORO_VOICE || "af_heart";
|
|
55
|
+
const voice = voices.includes(wanted) ? wanted : "af_heart";
|
|
56
|
+
if (voice !== wanted)
|
|
57
|
+
console.error(`[kokoro] unknown voice "${wanted}", using af_heart`);
|
|
58
|
+
send({ type: "ready", voice, voices, loadMs: Date.now() - t0 });
|
|
59
|
+
// One request at a time; a cancel flips its flag and the loop stops at the next chunk.
|
|
60
|
+
const cancelled = new Set();
|
|
61
|
+
let queue = Promise.resolve();
|
|
62
|
+
const speakRequest = async (id, chunks) => {
|
|
63
|
+
for (let seq = 0; seq < chunks.length; seq++) {
|
|
64
|
+
// Generating blocks this process's event loop, so let it read stdin (a cancel) between chunks.
|
|
65
|
+
await new Promise((r) => setImmediate(r));
|
|
66
|
+
if (cancelled.has(id))
|
|
67
|
+
break;
|
|
68
|
+
const audio = await tts.generate(chunks[seq], { voice, speed });
|
|
69
|
+
if (cancelled.has(id))
|
|
70
|
+
break;
|
|
71
|
+
send({ type: "audio", id, seq, pcm: floatToPcm16(audio.audio).toString("base64") });
|
|
72
|
+
}
|
|
73
|
+
send({ type: "done", id, cancelled: cancelled.delete(id), rssMb: Math.round(process.memoryUsage().rss / 1e6) });
|
|
74
|
+
};
|
|
75
|
+
const lines = createInterface({ input: process.stdin });
|
|
76
|
+
lines.on("line", (line) => {
|
|
77
|
+
let msg;
|
|
78
|
+
try {
|
|
79
|
+
msg = JSON.parse(line);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const id = String(msg.id ?? "");
|
|
85
|
+
if (msg.type === "cancel")
|
|
86
|
+
cancelled.add(id);
|
|
87
|
+
else if (msg.type === "speak" && Array.isArray(msg.chunks)) {
|
|
88
|
+
const chunks = msg.chunks.map(String);
|
|
89
|
+
queue = queue
|
|
90
|
+
.then(() => speakRequest(id, chunks))
|
|
91
|
+
.catch((e) => {
|
|
92
|
+
cancelled.delete(id);
|
|
93
|
+
send({ type: "error", id, message: e instanceof Error ? e.message : String(e) });
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
// The server closed our stdin (it exited, or was killed): stop too, never linger.
|
|
98
|
+
lines.on("close", () => void queue.finally(() => process.exit(0)));
|
|
99
|
+
}
|
|
100
|
+
main().catch((e) => {
|
|
101
|
+
send({ type: "error", message: e instanceof Error ? e.message : String(e), fatal: true });
|
|
102
|
+
out.end(() => process.exit(1));
|
|
103
|
+
});
|
package/dist/kokoro.js
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The optional Kokoro voice: a natural-sounding, on-device neural voice (Kokoro-82M, Apache-2.0).
|
|
3
|
+
*
|
|
4
|
+
* Off until the user installs it (voice_setup with kokoro=true): that installs kokoro-js with npm
|
|
5
|
+
* into ~/.cache/mac-voice-mcp/kokoro and downloads the model once. Nothing is bundled with this
|
|
6
|
+
* package, and nothing is ever downloaded without that consent.
|
|
7
|
+
*
|
|
8
|
+
* The model runs in a separate worker process (kokoro-worker.ts), kept warm between turns and
|
|
9
|
+
* stopped after VOICE_MCP_SERVER_IDLE_MINUTES. Text is spoken sentence by sentence: the first
|
|
10
|
+
* sentence plays while the next ones are generated, through SoX's `play` (gapless), or `afplay`.
|
|
11
|
+
*
|
|
12
|
+
* Any failure falls back to the built-in voice (see speak() in audio.ts), so a broken Kokoro
|
|
13
|
+
* install can never make the server mute.
|
|
14
|
+
*/
|
|
15
|
+
import { spawn } from "node:child_process";
|
|
16
|
+
import { existsSync } from "node:fs";
|
|
17
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
18
|
+
import os from "node:os";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { createInterface } from "node:readline";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
22
|
+
import { CONFIG, debug, IS_MAC, log } from "./config.js";
|
|
23
|
+
import { prepareForKokoro } from "./kokoro-text.js";
|
|
24
|
+
import { resamplePcm16, wavHeaderFor } from "./pcm.js";
|
|
25
|
+
import { CancelledError, isExecutable, run, SetupError, tail, which } from "./proc.js";
|
|
26
|
+
/** The kokoro-js release we install and test against. */
|
|
27
|
+
export const KOKORO_JS_VERSION = "1.2.1";
|
|
28
|
+
export const KOKORO_RATE = 24_000;
|
|
29
|
+
/** Approximate model download sizes. */
|
|
30
|
+
export const KOKORO_SIZES_MB = { fp32: 326, q8: 92 };
|
|
31
|
+
/** A short pause between sentences, so they don't run into each other. */
|
|
32
|
+
const GAP_MS = 120;
|
|
33
|
+
/** Restart the worker once it holds this much memory (onnxruntime can grow over a long session). */
|
|
34
|
+
const RECYCLE_RSS_MB = 1500;
|
|
35
|
+
/** A model already on disk loads in a few seconds; allow for a slow, busy machine. */
|
|
36
|
+
const LOAD_TIMEOUT_MS = 120_000;
|
|
37
|
+
/** First install: the download may be slow. */
|
|
38
|
+
const INSTALL_LOAD_TIMEOUT_MS = 30 * 60_000;
|
|
39
|
+
const ENTRY_MJS = "// Written by mac-voice-mcp: lets its Kokoro worker load kokoro-js and share transformers' settings.\n" +
|
|
40
|
+
'export { KokoroTTS } from "kokoro-js";\n' +
|
|
41
|
+
'export { env } from "@huggingface/transformers";\n';
|
|
42
|
+
export function kokoroFiles() {
|
|
43
|
+
const dir = CONFIG.kokoroDir;
|
|
44
|
+
const modelDir = path.join(dir, "models", "onnx-community", "Kokoro-82M-v1.0-ONNX", "onnx");
|
|
45
|
+
return {
|
|
46
|
+
dir,
|
|
47
|
+
entry: path.join(dir, "entry.mjs"),
|
|
48
|
+
pkg: path.join(dir, "node_modules", "kokoro-js", "package.json"),
|
|
49
|
+
model: path.join(modelDir, CONFIG.kokoroDtype === "q8" ? "model_quantized.onnx" : "model.onnx"),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** kokoro-js is installed (the model may still be missing). */
|
|
53
|
+
export function isKokoroInstalled() {
|
|
54
|
+
const f = kokoroFiles();
|
|
55
|
+
return existsSync(f.entry) && existsSync(f.pkg);
|
|
56
|
+
}
|
|
57
|
+
/** The model for the configured precision is on disk. */
|
|
58
|
+
export function isKokoroModelPresent() {
|
|
59
|
+
return existsSync(kokoroFiles().model);
|
|
60
|
+
}
|
|
61
|
+
/** Speak with Kokoro? Yes when the user installed it (VOICE_MCP_TTS=auto) or asked for it (=kokoro). */
|
|
62
|
+
export function kokoroEnabled() {
|
|
63
|
+
if (CONFIG.tts === "say")
|
|
64
|
+
return false;
|
|
65
|
+
if (CONFIG.tts === "kokoro")
|
|
66
|
+
return true;
|
|
67
|
+
return isKokoroInstalled() && isKokoroModelPresent();
|
|
68
|
+
}
|
|
69
|
+
/** Kokoro failed partway: what hadn't been spoken yet, so the built-in voice can finish it. */
|
|
70
|
+
export class KokoroError extends Error {
|
|
71
|
+
unspoken;
|
|
72
|
+
name = "KokoroError";
|
|
73
|
+
constructor(message, unspoken) {
|
|
74
|
+
super(message);
|
|
75
|
+
this.unspoken = unspoken;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
class KokoroWorker {
|
|
79
|
+
child = null;
|
|
80
|
+
ready = null;
|
|
81
|
+
requests = new Map();
|
|
82
|
+
idleTimer;
|
|
83
|
+
nextId = 1;
|
|
84
|
+
logTail = "";
|
|
85
|
+
info = null;
|
|
86
|
+
onProgress;
|
|
87
|
+
get running() {
|
|
88
|
+
return !!this.child && this.child.exitCode === null && this.child.signalCode === null;
|
|
89
|
+
}
|
|
90
|
+
/** Start (or reuse) the worker; resolves once the model is loaded. */
|
|
91
|
+
start(opts = {}) {
|
|
92
|
+
this.touch();
|
|
93
|
+
if (this.ready && this.running)
|
|
94
|
+
return this.ready;
|
|
95
|
+
const script = fileURLToPath(new URL("./kokoro-worker.js", import.meta.url));
|
|
96
|
+
const child = spawn(process.execPath, [script], {
|
|
97
|
+
stdio: ["pipe", "pipe", "pipe", "pipe"],
|
|
98
|
+
windowsHide: true,
|
|
99
|
+
env: {
|
|
100
|
+
...process.env,
|
|
101
|
+
KOKORO_DIR: CONFIG.kokoroDir,
|
|
102
|
+
KOKORO_DTYPE: CONFIG.kokoroDtype,
|
|
103
|
+
KOKORO_VOICE: CONFIG.kokoroVoice,
|
|
104
|
+
KOKORO_SPEED: String(CONFIG.kokoroSpeed),
|
|
105
|
+
KOKORO_ALLOW_DOWNLOAD: opts.allowDownload ? "1" : "0",
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
this.child = child;
|
|
109
|
+
this.logTail = "";
|
|
110
|
+
debug("kokoro: starting worker");
|
|
111
|
+
const keepLog = (d) => {
|
|
112
|
+
this.logTail = (this.logTail + d).slice(-3000);
|
|
113
|
+
debug("kokoro:", d.trimEnd());
|
|
114
|
+
};
|
|
115
|
+
child.stdout.setEncoding("utf8").on("data", keepLog);
|
|
116
|
+
child.stderr.setEncoding("utf8").on("data", keepLog);
|
|
117
|
+
child.stdin.on("error", () => { });
|
|
118
|
+
this.ready = new Promise((resolve, reject) => {
|
|
119
|
+
const timer = setTimeout(() => {
|
|
120
|
+
reject(new Error("the Kokoro voice took too long to load"));
|
|
121
|
+
this.stop();
|
|
122
|
+
}, opts.allowDownload ? INSTALL_LOAD_TIMEOUT_MS : LOAD_TIMEOUT_MS);
|
|
123
|
+
timer.unref();
|
|
124
|
+
createInterface({ input: child.stdio[3] }).on("line", (line) => {
|
|
125
|
+
let msg;
|
|
126
|
+
try {
|
|
127
|
+
msg = JSON.parse(line);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const req = typeof msg.id === "string" ? this.requests.get(msg.id) : undefined;
|
|
133
|
+
switch (msg.type) {
|
|
134
|
+
case "ready":
|
|
135
|
+
clearTimeout(timer);
|
|
136
|
+
this.info = { voice: String(msg.voice), voices: msg.voices ?? [], loadMs: Number(msg.loadMs) };
|
|
137
|
+
debug(`kokoro: ready in ${this.info.loadMs} ms (voice ${this.info.voice})`);
|
|
138
|
+
resolve(this.info);
|
|
139
|
+
break;
|
|
140
|
+
case "progress":
|
|
141
|
+
this.onProgress?.(String(msg.message));
|
|
142
|
+
break;
|
|
143
|
+
case "audio":
|
|
144
|
+
req?.onAudio(Number(msg.seq), Buffer.from(String(msg.pcm), "base64"));
|
|
145
|
+
break;
|
|
146
|
+
case "done":
|
|
147
|
+
if (req && typeof msg.id === "string") {
|
|
148
|
+
this.requests.delete(msg.id);
|
|
149
|
+
req.resolve({ rssMb: Number(msg.rssMb), cancelled: msg.cancelled === true });
|
|
150
|
+
}
|
|
151
|
+
break;
|
|
152
|
+
case "error":
|
|
153
|
+
if (msg.fatal) {
|
|
154
|
+
clearTimeout(timer);
|
|
155
|
+
reject(new Error(String(msg.message)));
|
|
156
|
+
}
|
|
157
|
+
else if (req && typeof msg.id === "string") {
|
|
158
|
+
this.requests.delete(msg.id);
|
|
159
|
+
req.reject(new Error(String(msg.message)));
|
|
160
|
+
}
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
child.on("error", (e) => {
|
|
165
|
+
clearTimeout(timer);
|
|
166
|
+
reject(e);
|
|
167
|
+
});
|
|
168
|
+
child.on("exit", (code, signal) => {
|
|
169
|
+
clearTimeout(timer);
|
|
170
|
+
const why = new Error(`the Kokoro worker stopped (${signal ?? `exit ${code}`})${this.logTail ? `: ${tail(this.logTail, 3)}` : ""}`);
|
|
171
|
+
reject(why); // no-op once ready
|
|
172
|
+
for (const r of this.requests.values())
|
|
173
|
+
r.reject(why);
|
|
174
|
+
this.requests.clear();
|
|
175
|
+
if (this.child === child) {
|
|
176
|
+
this.child = null;
|
|
177
|
+
this.ready = null;
|
|
178
|
+
this.info = null;
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
this.ready.catch(() => { }); // callers handle it; don't crash on an unobserved rejection
|
|
183
|
+
return this.ready;
|
|
184
|
+
}
|
|
185
|
+
/** Generate `chunks` in order; onAudio receives each chunk's 24 kHz PCM as soon as it's ready. */
|
|
186
|
+
speak(chunks, onAudio, signal) {
|
|
187
|
+
const child = this.child;
|
|
188
|
+
if (!child || !this.running)
|
|
189
|
+
return Promise.reject(new Error("the Kokoro worker is not running"));
|
|
190
|
+
const id = String(this.nextId++);
|
|
191
|
+
this.touch();
|
|
192
|
+
return new Promise((resolve, reject) => {
|
|
193
|
+
// Cancel: stop waiting right away; the worker drops the rest at its next sentence.
|
|
194
|
+
const onAbort = () => {
|
|
195
|
+
child.stdin.write(JSON.stringify({ type: "cancel", id }) + "\n");
|
|
196
|
+
const req = this.requests.get(id);
|
|
197
|
+
this.requests.delete(id);
|
|
198
|
+
req?.resolve({ rssMb: 0, cancelled: true });
|
|
199
|
+
};
|
|
200
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
201
|
+
const done = () => {
|
|
202
|
+
signal?.removeEventListener("abort", onAbort);
|
|
203
|
+
this.touch();
|
|
204
|
+
};
|
|
205
|
+
if (signal?.aborted)
|
|
206
|
+
return resolve({ rssMb: 0, cancelled: true });
|
|
207
|
+
this.requests.set(id, {
|
|
208
|
+
onAudio,
|
|
209
|
+
resolve: (r) => {
|
|
210
|
+
done();
|
|
211
|
+
if (r.rssMb > RECYCLE_RSS_MB) {
|
|
212
|
+
debug(`kokoro: worker uses ${r.rssMb} MB — restarting it next time`);
|
|
213
|
+
this.stop();
|
|
214
|
+
}
|
|
215
|
+
resolve(r);
|
|
216
|
+
},
|
|
217
|
+
reject: (e) => {
|
|
218
|
+
done();
|
|
219
|
+
reject(e);
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
child.stdin.write(JSON.stringify({ type: "speak", id, chunks }) + "\n");
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
touch() {
|
|
226
|
+
clearTimeout(this.idleTimer);
|
|
227
|
+
this.idleTimer = setTimeout(() => {
|
|
228
|
+
debug("kokoro: idle — stopping the worker to free memory");
|
|
229
|
+
this.stop();
|
|
230
|
+
}, CONFIG.serverIdleMinutes * 60_000);
|
|
231
|
+
this.idleTimer.unref();
|
|
232
|
+
}
|
|
233
|
+
stop() {
|
|
234
|
+
clearTimeout(this.idleTimer);
|
|
235
|
+
const child = this.child;
|
|
236
|
+
this.child = null;
|
|
237
|
+
this.ready = null;
|
|
238
|
+
this.info = null;
|
|
239
|
+
if (child && child.exitCode === null) {
|
|
240
|
+
child.stdin?.end(); // the worker exits when its stdin closes
|
|
241
|
+
setTimeout(() => child.exitCode === null && child.kill("SIGKILL"), 2000).unref();
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
const worker = new KokoroWorker();
|
|
246
|
+
export function stopKokoro() {
|
|
247
|
+
worker.stop();
|
|
248
|
+
}
|
|
249
|
+
/** The voice actually in use, once the worker has loaded (for reports). */
|
|
250
|
+
export function kokoroVoiceLabel() {
|
|
251
|
+
const voice = worker.info?.voice ?? CONFIG.kokoroVoice;
|
|
252
|
+
return `Kokoro ${voice} (${CONFIG.kokoroDtype})`;
|
|
253
|
+
}
|
|
254
|
+
/** SoX `play`, streaming raw PCM: gapless, and starts with the first sentence. */
|
|
255
|
+
function soxPlayer(bin) {
|
|
256
|
+
const child = spawn(bin, ["-q", "-t", "raw", "-r", String(KOKORO_RATE), "-e", "signed-integer", "-b", "16", "-c", "1", "-"], {
|
|
257
|
+
stdio: ["pipe", "ignore", "pipe"],
|
|
258
|
+
windowsHide: true,
|
|
259
|
+
});
|
|
260
|
+
let stderr = "";
|
|
261
|
+
child.stderr.setEncoding("utf8").on("data", (d) => (stderr = (stderr + d).slice(-2000)));
|
|
262
|
+
child.stdin.on("error", () => { });
|
|
263
|
+
const closed = new Promise((resolve, reject) => {
|
|
264
|
+
child.on("error", reject);
|
|
265
|
+
child.on("close", resolve);
|
|
266
|
+
});
|
|
267
|
+
closed.catch(() => { });
|
|
268
|
+
return {
|
|
269
|
+
write: (pcm) => void child.stdin.write(pcm),
|
|
270
|
+
finish: async () => {
|
|
271
|
+
child.stdin.end();
|
|
272
|
+
const code = await closed;
|
|
273
|
+
if (code !== 0)
|
|
274
|
+
throw new Error(`play failed (exit ${code}): ${tail(stderr) || "no output"}`);
|
|
275
|
+
},
|
|
276
|
+
kill: () => {
|
|
277
|
+
if (child.exitCode === null)
|
|
278
|
+
child.kill("SIGKILL");
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
/** macOS without SoX: one short WAV per sentence through afplay, back to back. */
|
|
283
|
+
function afplayPlayer() {
|
|
284
|
+
let dir;
|
|
285
|
+
let queue = Promise.resolve();
|
|
286
|
+
let current = null;
|
|
287
|
+
let killed = false;
|
|
288
|
+
let n = 0;
|
|
289
|
+
const playOne = async (pcm) => {
|
|
290
|
+
if (killed)
|
|
291
|
+
return;
|
|
292
|
+
dir ??= mkdtemp(path.join(os.tmpdir(), "voice-mcp-kokoro-"));
|
|
293
|
+
const file = path.join(await dir, `${n++}.wav`);
|
|
294
|
+
await writeFile(file, Buffer.concat([wavHeaderFor(pcm.length, KOKORO_RATE), pcm]));
|
|
295
|
+
if (killed)
|
|
296
|
+
return;
|
|
297
|
+
await new Promise((resolve, reject) => {
|
|
298
|
+
const child = spawn("/usr/bin/afplay", [file], { stdio: "ignore" });
|
|
299
|
+
current = child;
|
|
300
|
+
child.on("error", reject);
|
|
301
|
+
child.on("close", (code) => (code === 0 || killed ? resolve() : reject(new Error(`afplay failed (exit ${code})`))));
|
|
302
|
+
});
|
|
303
|
+
};
|
|
304
|
+
const cleanup = async () => {
|
|
305
|
+
if (dir)
|
|
306
|
+
await rm(await dir, { recursive: true, force: true }).catch(() => { });
|
|
307
|
+
};
|
|
308
|
+
return {
|
|
309
|
+
write: (pcm) => {
|
|
310
|
+
queue = queue.then(() => playOne(pcm));
|
|
311
|
+
},
|
|
312
|
+
finish: async () => {
|
|
313
|
+
try {
|
|
314
|
+
await queue;
|
|
315
|
+
}
|
|
316
|
+
finally {
|
|
317
|
+
await cleanup();
|
|
318
|
+
}
|
|
319
|
+
},
|
|
320
|
+
kill: () => {
|
|
321
|
+
killed = true;
|
|
322
|
+
current?.kill("SIGKILL");
|
|
323
|
+
void queue.catch(() => { }).finally(cleanup);
|
|
324
|
+
},
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
function openPlayer() {
|
|
328
|
+
const play = which("play");
|
|
329
|
+
if (play)
|
|
330
|
+
return soxPlayer(play);
|
|
331
|
+
if (IS_MAC && isExecutable("/usr/bin/afplay"))
|
|
332
|
+
return afplayPlayer();
|
|
333
|
+
throw new SetupError("The Kokoro voice needs SoX's `play` to play audio (`brew install sox`).");
|
|
334
|
+
}
|
|
335
|
+
const silence = (ms) => Buffer.alloc(Math.round((KOKORO_RATE * ms) / 1000) * 2);
|
|
336
|
+
/** Speak `text` with Kokoro. Throws KokoroError (with what's still unspoken) if Kokoro fails. */
|
|
337
|
+
export async function speakKokoro(text, signal) {
|
|
338
|
+
const chunks = prepareForKokoro(text);
|
|
339
|
+
if (!chunks.length)
|
|
340
|
+
return { firstAudioMs: 0, coldStart: false };
|
|
341
|
+
if (!isKokoroInstalled())
|
|
342
|
+
throw new KokoroError("the Kokoro voice isn't installed — call voice_setup with kokoro=true", chunks);
|
|
343
|
+
if (signal?.aborted)
|
|
344
|
+
throw new CancelledError();
|
|
345
|
+
const t0 = Date.now();
|
|
346
|
+
const coldStart = !worker.running;
|
|
347
|
+
let player = null;
|
|
348
|
+
let handed = 0; // chunks given to the player
|
|
349
|
+
let firstAudioMs = 0;
|
|
350
|
+
try {
|
|
351
|
+
await worker.start();
|
|
352
|
+
if (signal?.aborted)
|
|
353
|
+
throw new CancelledError();
|
|
354
|
+
player = openPlayer();
|
|
355
|
+
const p = player;
|
|
356
|
+
const onAbort = () => p.kill();
|
|
357
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
358
|
+
try {
|
|
359
|
+
const r = await worker.speak(chunks, (seq, pcm) => {
|
|
360
|
+
if (seq === 0)
|
|
361
|
+
firstAudioMs = Date.now() - t0;
|
|
362
|
+
p.write(seq < chunks.length - 1 ? Buffer.concat([pcm, silence(GAP_MS)]) : pcm);
|
|
363
|
+
handed = seq + 1;
|
|
364
|
+
}, signal);
|
|
365
|
+
if (r.cancelled || signal?.aborted)
|
|
366
|
+
throw new CancelledError();
|
|
367
|
+
await p.finish();
|
|
368
|
+
}
|
|
369
|
+
finally {
|
|
370
|
+
signal?.removeEventListener("abort", onAbort);
|
|
371
|
+
}
|
|
372
|
+
return { firstAudioMs, coldStart };
|
|
373
|
+
}
|
|
374
|
+
catch (e) {
|
|
375
|
+
player?.kill();
|
|
376
|
+
if (e instanceof CancelledError || signal?.aborted)
|
|
377
|
+
throw new CancelledError();
|
|
378
|
+
if (e instanceof SetupError)
|
|
379
|
+
throw new KokoroError(e.message, chunks);
|
|
380
|
+
throw new KokoroError(e instanceof Error ? e.message : String(e), chunks.slice(handed));
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
/** Speak `text` with Kokoro into a 16 kHz mono 16-bit WAV (for doctor and the round-trip tests). */
|
|
384
|
+
export async function synthesizeKokoroToFile(text, outFile) {
|
|
385
|
+
const chunks = prepareForKokoro(text);
|
|
386
|
+
if (!isKokoroInstalled())
|
|
387
|
+
throw new SetupError("the Kokoro voice isn't installed — call voice_setup with kokoro=true");
|
|
388
|
+
const t0 = Date.now();
|
|
389
|
+
const coldStart = !worker.running;
|
|
390
|
+
await worker.start();
|
|
391
|
+
const parts = [];
|
|
392
|
+
let firstAudioMs = 0;
|
|
393
|
+
await worker.speak(chunks, (seq, pcm) => {
|
|
394
|
+
if (seq === 0)
|
|
395
|
+
firstAudioMs = Date.now() - t0;
|
|
396
|
+
parts[seq] = seq < chunks.length - 1 ? Buffer.concat([pcm, silence(GAP_MS)]) : pcm;
|
|
397
|
+
});
|
|
398
|
+
const pcm16k = resamplePcm16(Buffer.concat(parts), KOKORO_RATE, 16_000);
|
|
399
|
+
await writeFile(outFile, Buffer.concat([wavHeaderFor(pcm16k.length, 16_000), pcm16k]));
|
|
400
|
+
return { firstAudioMs, coldStart };
|
|
401
|
+
}
|
|
402
|
+
// ---------------------------------------------------------------------------
|
|
403
|
+
// Installing (only from voice_setup with kokoro=true, after the user agreed)
|
|
404
|
+
// ---------------------------------------------------------------------------
|
|
405
|
+
/** npm from the same Node installation that runs this server, else from PATH. */
|
|
406
|
+
function findNpm() {
|
|
407
|
+
const sibling = path.join(path.dirname(process.execPath), process.platform === "win32" ? "npm.cmd" : "npm");
|
|
408
|
+
return isExecutable(sibling) ? sibling : which("npm");
|
|
409
|
+
}
|
|
410
|
+
/** Install kokoro-js and download the model. Returns a one-line summary. */
|
|
411
|
+
export async function installKokoro(onProgress) {
|
|
412
|
+
const f = kokoroFiles();
|
|
413
|
+
await mkdir(f.dir, { recursive: true });
|
|
414
|
+
if (!existsSync(f.pkg)) {
|
|
415
|
+
const npm = findNpm();
|
|
416
|
+
if (!npm)
|
|
417
|
+
throw new SetupError("npm was not found, so kokoro-js can't be installed. Install Node.js from https://nodejs.org (it includes npm).");
|
|
418
|
+
const pkgJson = path.join(f.dir, "package.json");
|
|
419
|
+
// "wx": create it only if it isn't there yet, in one step (no check-then-write race).
|
|
420
|
+
await writeFile(pkgJson, JSON.stringify({ name: "mac-voice-mcp-kokoro", private: true, type: "module", description: "The optional Kokoro voice for mac-voice-mcp." }, null, 2) + "\n", { flag: "wx" }).catch((e) => {
|
|
421
|
+
if (e.code !== "EEXIST")
|
|
422
|
+
throw e;
|
|
423
|
+
});
|
|
424
|
+
onProgress?.(`installing kokoro-js ${KOKORO_JS_VERSION} with npm`);
|
|
425
|
+
log(`Running: npm install kokoro-js@${KOKORO_JS_VERSION} in ${f.dir}`);
|
|
426
|
+
const r = await run(npm, ["install", "--no-audit", "--no-fund", "--loglevel=error", `kokoro-js@${KOKORO_JS_VERSION}`], {
|
|
427
|
+
timeoutMs: 20 * 60_000,
|
|
428
|
+
cwd: f.dir,
|
|
429
|
+
// npm is a Node script: make sure it finds this Node, even from a GUI app's minimal PATH.
|
|
430
|
+
env: { PATH: [path.dirname(process.execPath), process.env.PATH ?? ""].join(path.delimiter) },
|
|
431
|
+
});
|
|
432
|
+
if (r.code !== 0)
|
|
433
|
+
throw new Error(`npm install kokoro-js failed (exit ${r.code}): ${tail(r.stderr, 4)}`);
|
|
434
|
+
}
|
|
435
|
+
await writeFile(f.entry, ENTRY_MJS); // always (re)written: tiny, and it repairs an edited or stale one
|
|
436
|
+
// Load it once, downloading the model if it isn't there yet. This is also the install's self-test.
|
|
437
|
+
const hadModel = isKokoroModelPresent();
|
|
438
|
+
worker.stop();
|
|
439
|
+
worker.onProgress = onProgress;
|
|
440
|
+
try {
|
|
441
|
+
const info = await worker.start({ allowDownload: true });
|
|
442
|
+
worker.stop(); // the next turn starts it normally (downloads off)
|
|
443
|
+
return (`Installed the Kokoro voice (${info.voice}${hadModel ? "" : `, downloaded the ~${KOKORO_SIZES_MB[CONFIG.kokoroDtype]} MB model`}). ` +
|
|
444
|
+
"It's used from the next turn on.");
|
|
445
|
+
}
|
|
446
|
+
finally {
|
|
447
|
+
worker.onProgress = undefined;
|
|
448
|
+
}
|
|
449
|
+
}
|