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
package/dist/pcm.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** Small PCM helpers for the Kokoro voice (pure, unit-tested). */
|
|
2
|
+
/** Float samples (-1…1) → 16-bit little-endian PCM, clipped. */
|
|
3
|
+
export function floatToPcm16(samples) {
|
|
4
|
+
const buf = Buffer.alloc(samples.length * 2);
|
|
5
|
+
for (let i = 0; i < samples.length; i++) {
|
|
6
|
+
const s = Math.max(-1, Math.min(1, samples[i]));
|
|
7
|
+
buf.writeInt16LE(Math.round(s < 0 ? s * 0x8000 : s * 0x7fff), i * 2);
|
|
8
|
+
}
|
|
9
|
+
return buf;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Resample 16-bit mono PCM by linear interpolation (24 kHz → 16 kHz for whisper). Good enough
|
|
13
|
+
* for speech recognition, which is all it's used for; playback always uses the original rate.
|
|
14
|
+
*/
|
|
15
|
+
export function resamplePcm16(pcm, fromRate, toRate) {
|
|
16
|
+
const inCount = Math.floor(pcm.length / 2);
|
|
17
|
+
if (fromRate === toRate || inCount === 0)
|
|
18
|
+
return Buffer.from(pcm.subarray(0, inCount * 2));
|
|
19
|
+
const outCount = Math.floor((inCount * toRate) / fromRate);
|
|
20
|
+
const out = Buffer.alloc(outCount * 2);
|
|
21
|
+
const step = fromRate / toRate;
|
|
22
|
+
for (let i = 0; i < outCount; i++) {
|
|
23
|
+
const x = i * step;
|
|
24
|
+
const i0 = Math.floor(x);
|
|
25
|
+
const i1 = Math.min(i0 + 1, inCount - 1);
|
|
26
|
+
const frac = x - i0;
|
|
27
|
+
const v = pcm.readInt16LE(i0 * 2) * (1 - frac) + pcm.readInt16LE(i1 * 2) * frac;
|
|
28
|
+
out.writeInt16LE(Math.round(v), i * 2);
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
/** A 44-byte WAV header for 16-bit mono PCM. */
|
|
33
|
+
export function wavHeaderFor(dataBytes, sampleRate) {
|
|
34
|
+
const h = Buffer.alloc(44);
|
|
35
|
+
h.write("RIFF", 0);
|
|
36
|
+
h.writeUInt32LE(36 + dataBytes, 4);
|
|
37
|
+
h.write("WAVE", 8);
|
|
38
|
+
h.write("fmt ", 12);
|
|
39
|
+
h.writeUInt32LE(16, 16);
|
|
40
|
+
h.writeUInt16LE(1, 20); // PCM
|
|
41
|
+
h.writeUInt16LE(1, 22); // mono
|
|
42
|
+
h.writeUInt32LE(sampleRate, 24);
|
|
43
|
+
h.writeUInt32LE(sampleRate * 2, 28);
|
|
44
|
+
h.writeUInt16LE(2, 32);
|
|
45
|
+
h.writeUInt16LE(16, 34);
|
|
46
|
+
h.write("data", 36);
|
|
47
|
+
h.writeUInt32LE(dataBytes, 40);
|
|
48
|
+
return h;
|
|
49
|
+
}
|
package/dist/proc.js
CHANGED
|
@@ -64,6 +64,7 @@ export function run(cmd, args, opts = {}) {
|
|
|
64
64
|
const child = spawn(cmd, args, {
|
|
65
65
|
stdio: [opts.input !== undefined ? "pipe" : "ignore", "pipe", "pipe"],
|
|
66
66
|
windowsHide: true,
|
|
67
|
+
cwd: opts.cwd,
|
|
67
68
|
env: opts.env ? { ...process.env, ...opts.env } : process.env,
|
|
68
69
|
});
|
|
69
70
|
activeChildren.add(child);
|
package/dist/server.js
CHANGED
|
@@ -3,6 +3,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { CONFIG, DEFAULT_LISTEN_SECONDS, log, MAX_LISTEN_SECONDS, PKG } from "./config.js";
|
|
6
|
+
import { stopKokoro } from "./kokoro.js";
|
|
6
7
|
import { locateModel } from "./model.js";
|
|
7
8
|
import { activeChildren, SetupError } from "./proc.js";
|
|
8
9
|
import { runSetupFlow } from "./setup.js";
|
|
@@ -74,11 +75,16 @@ export function createServer() {
|
|
|
74
75
|
.boolean()
|
|
75
76
|
.optional()
|
|
76
77
|
.describe("false (default): only check. true: brew install missing packages and download the model. Ask the user first."),
|
|
78
|
+
kokoro: z
|
|
79
|
+
.boolean()
|
|
80
|
+
.optional()
|
|
81
|
+
.describe("true: install the optional Kokoro voice — a natural-sounding neural voice that runs on this Mac " +
|
|
82
|
+
"(kokoro-js via npm and a one-time model download, about 1 GB on disk). Ask the user first."),
|
|
77
83
|
},
|
|
78
84
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
79
|
-
}, async ({ install }, extra) => {
|
|
85
|
+
}, async ({ install, kokoro }, extra) => {
|
|
80
86
|
try {
|
|
81
|
-
const outcome = await exclusive(() => runSetupFlow(install === true, progressReporter(extra)));
|
|
87
|
+
const outcome = await exclusive(() => runSetupFlow({ install: install === true, kokoro: kokoro === true, onProgress: progressReporter(extra) }));
|
|
82
88
|
return { content: [{ type: "text", text: outcome.report }] };
|
|
83
89
|
}
|
|
84
90
|
catch (err) {
|
|
@@ -110,6 +116,7 @@ export async function startServer() {
|
|
|
110
116
|
return;
|
|
111
117
|
shuttingDown = true;
|
|
112
118
|
stopWhisperServer();
|
|
119
|
+
stopKokoro();
|
|
113
120
|
for (const child of activeChildren)
|
|
114
121
|
child.kill("SIGTERM");
|
|
115
122
|
try {
|
package/dist/setup.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { statSync } from "node:fs";
|
|
10
10
|
import { chooseVoice, describeRecorder, findRecorder, findTts, resetVoiceChoice, VOICE_UPGRADE_HINT } from "./audio.js";
|
|
11
11
|
import { CONFIG, IS_MAC, IS_WIN, log } from "./config.js";
|
|
12
|
+
import { installKokoro, isKokoroInstalled, isKokoroModelPresent, KOKORO_SIZES_MB, kokoroVoiceLabel } from "./kokoro.js";
|
|
12
13
|
import { ensureModel, isModelDownloading, locateModel, modelSource, MODEL_SIZES_MB } from "./model.js";
|
|
13
14
|
import { isExecutable, resetWhichCache, run, sleep, tail, which } from "./proc.js";
|
|
14
15
|
import { describeStt, findWhisperCli, findWhisperServer } from "./stt.js";
|
|
@@ -21,6 +22,9 @@ export function findBrew() {
|
|
|
21
22
|
}
|
|
22
23
|
let brewJob = null;
|
|
23
24
|
let modelJob = null;
|
|
25
|
+
let kokoroJob = null;
|
|
26
|
+
/** The Kokoro install's latest progress line. */
|
|
27
|
+
let kokoroProgress = "";
|
|
24
28
|
/** Results of finished jobs not yet shown to the user. */
|
|
25
29
|
const finishedMessages = [];
|
|
26
30
|
function startJob(label, work) {
|
|
@@ -58,6 +62,10 @@ function startModelDownload() {
|
|
|
58
62
|
return `Downloaded the ${CONFIG.modelName} speech model.`;
|
|
59
63
|
});
|
|
60
64
|
}
|
|
65
|
+
function startKokoroInstall() {
|
|
66
|
+
kokoroProgress = "starting";
|
|
67
|
+
return startJob("Installing the Kokoro voice", () => installKokoro((m) => (kokoroProgress = m)));
|
|
68
|
+
}
|
|
61
69
|
const running = (j) => !!j && !j.done;
|
|
62
70
|
const elapsed = (j) => `${Math.round((Date.now() - j.startedAt) / 1000)}s`;
|
|
63
71
|
// --- Checks -------------------------------------------------------------------------------
|
|
@@ -68,13 +76,18 @@ async function checkRequirements() {
|
|
|
68
76
|
checks.push(tts
|
|
69
77
|
? { label: "Text-to-speech", status: "ok", detail: IS_MAC ? "macOS `say` (built in)" : tts }
|
|
70
78
|
: { label: "Text-to-speech", status: "missing", detail: "no engine found — install espeak-ng (`sudo apt install espeak-ng`)" });
|
|
79
|
+
const kokoro = kokoroCheck();
|
|
71
80
|
if (tts && IS_MAC) {
|
|
72
81
|
resetVoiceChoice(); // pick up a voice downloaded since the last check
|
|
73
82
|
const choice = await chooseVoice();
|
|
74
|
-
checks.push(
|
|
75
|
-
? { label: "Voice", status: "
|
|
76
|
-
:
|
|
83
|
+
checks.push(kokoro?.status === "ok"
|
|
84
|
+
? { label: "Voice", status: "ok", detail: `${choice.label} (used if the Kokoro voice ever fails)` }
|
|
85
|
+
: choice.canUpgrade
|
|
86
|
+
? { label: "Voice", status: "optional", detail: `${choice.label} — ${VOICE_UPGRADE_HINT}` }
|
|
87
|
+
: { label: "Voice", status: "ok", detail: choice.label });
|
|
77
88
|
}
|
|
89
|
+
if (kokoro)
|
|
90
|
+
checks.push(kokoro);
|
|
78
91
|
const rec = findRecorder();
|
|
79
92
|
if (!rec) {
|
|
80
93
|
checks.push({ label: "Recorder", status: brewing ? "installing" : "missing", detail: "SoX is not installed", brew: IS_WIN ? undefined : "sox" });
|
|
@@ -137,12 +150,34 @@ async function checkRequirements() {
|
|
|
137
150
|
}
|
|
138
151
|
return checks;
|
|
139
152
|
}
|
|
153
|
+
/** The optional Kokoro voice: shown once it's installed or asked for (VOICE_MCP_TTS=kokoro), or while installing. */
|
|
154
|
+
function kokoroCheck() {
|
|
155
|
+
const label = "Kokoro voice";
|
|
156
|
+
if (running(kokoroJob))
|
|
157
|
+
return { label, status: "installing", detail: `${kokoroProgress} (${elapsed(kokoroJob)} so far)` };
|
|
158
|
+
if (CONFIG.tts === "say")
|
|
159
|
+
return null;
|
|
160
|
+
const installed = isKokoroInstalled();
|
|
161
|
+
const size = `~${KOKORO_SIZES_MB[CONFIG.kokoroDtype]} MB`;
|
|
162
|
+
if (installed && isKokoroModelPresent()) {
|
|
163
|
+
return { label, status: "ok", detail: `${kokoroVoiceLabel()} — the natural on-device voice, used for speaking (VOICE_MCP_TTS=say turns it off)` };
|
|
164
|
+
}
|
|
165
|
+
if (installed) {
|
|
166
|
+
return { label, status: "optional", detail: `installed, but its model isn't downloaded yet (${size}) — until then the built-in voice speaks`, kokoro: true };
|
|
167
|
+
}
|
|
168
|
+
if (CONFIG.tts === "kokoro") {
|
|
169
|
+
return {
|
|
170
|
+
label,
|
|
171
|
+
status: "optional",
|
|
172
|
+
detail: `VOICE_MCP_TTS=kokoro, but it isn't installed — a one-time install (kokoro-js with npm, and a ${size} model; about 1 GB on disk) into ${CONFIG.kokoroDir}. Until then the built-in voice speaks`,
|
|
173
|
+
kokoro: true,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
140
178
|
// --- The flow ------------------------------------------------------------------------------
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
* @param onProgress optional heartbeat while waiting (used for MCP progress notifications).
|
|
144
|
-
*/
|
|
145
|
-
export async function runSetupFlow(install, onProgress) {
|
|
179
|
+
export async function runSetupFlow(opts = {}) {
|
|
180
|
+
const { install = false, onProgress } = opts;
|
|
146
181
|
resetWhichCache(); // see anything installed since the last check (e.g. `brew install sox` in a terminal)
|
|
147
182
|
let checks = await checkRequirements();
|
|
148
183
|
if (install) {
|
|
@@ -154,17 +189,22 @@ export async function runSetupFlow(install, onProgress) {
|
|
|
154
189
|
if (checks.some((c) => c.model && c.status === "missing") && !running(modelJob))
|
|
155
190
|
modelJob = startModelDownload();
|
|
156
191
|
}
|
|
192
|
+
if (opts.kokoro && !running(kokoroJob) && !(isKokoroInstalled() && isKokoroModelPresent()))
|
|
193
|
+
kokoroJob = startKokoroInstall();
|
|
157
194
|
// Wait (bounded) for anything in flight, with a heartbeat.
|
|
158
|
-
const jobs = [brewJob, modelJob].filter(running);
|
|
195
|
+
const jobs = [brewJob, modelJob, kokoroJob].filter(running);
|
|
159
196
|
if (jobs.length) {
|
|
160
197
|
const deadline = Date.now() + INSTALL_WAIT_MS;
|
|
161
198
|
while (jobs.some((j) => !j.done) && Date.now() < deadline) {
|
|
162
|
-
onProgress?.(jobs
|
|
199
|
+
onProgress?.(jobs
|
|
200
|
+
.filter((j) => !j.done)
|
|
201
|
+
.map((j) => `${j.label} (${j === kokoroJob ? `${kokoroProgress}, ` : ""}${elapsed(j)})`)
|
|
202
|
+
.join("; "));
|
|
163
203
|
await Promise.race([Promise.all(jobs.map((j) => j.promise)), sleep(5000)]);
|
|
164
204
|
}
|
|
165
205
|
checks = await checkRequirements();
|
|
166
206
|
}
|
|
167
|
-
const installing = running(brewJob) || running(modelJob);
|
|
207
|
+
const installing = running(brewJob) || running(modelJob) || running(kokoroJob);
|
|
168
208
|
const ready = checks.every((c) => c.status === "ok" || c.status === "optional");
|
|
169
209
|
const icon = { ok: "✔", missing: "✘", optional: "•", installing: "…" };
|
|
170
210
|
const done = finishedMessages.splice(0);
|
|
@@ -176,7 +216,7 @@ export async function runSetupFlow(install, onProgress) {
|
|
|
176
216
|
];
|
|
177
217
|
if (done.length)
|
|
178
218
|
lines.push("", "What was done:", ...done.map((d) => `- ${d}`));
|
|
179
|
-
if (install && !done.length && !installing && ready)
|
|
219
|
+
if ((install || opts.kokoro) && !done.length && !installing && ready)
|
|
180
220
|
lines.push("", "Nothing to install — everything was already present.");
|
|
181
221
|
const toInstall = [...new Set(checks.filter((c) => c.status === "missing" && c.brew).map((c) => c.brew))];
|
|
182
222
|
const needsModel = checks.some((c) => c.model && c.status === "missing");
|
|
@@ -188,8 +228,13 @@ export async function runSetupFlow(install, onProgress) {
|
|
|
188
228
|
lines.push(`Recommended: ask the user whether to brew install ${recommended.join(" ")} (see • above). ` +
|
|
189
229
|
"Only if they agree, call voice_setup with install=true.");
|
|
190
230
|
}
|
|
231
|
+
if (checks.some((c) => c.kokoro && c.status === "optional") && !opts.kokoro) {
|
|
232
|
+
lines.push(`Optional: ask the user whether to install the Kokoro voice (a one-time install, about 1 GB on disk). ` +
|
|
233
|
+
"Only if they agree, call voice_setup with kokoro=true.");
|
|
234
|
+
}
|
|
191
235
|
if (checks.some((c) => c.label === "Voice" && c.status === "optional")) {
|
|
192
|
-
lines.push("Mention the optional voice tip (•) to the user once — it makes the voice sound far more natural.
|
|
236
|
+
lines.push("Mention the optional voice tip (•) to the user once — it makes the voice sound far more natural. " +
|
|
237
|
+
"If they'd rather not download a macOS voice, the Kokoro voice (voice_setup with kokoro=true, about 1 GB on disk) is the other option. Nothing else to do.");
|
|
193
238
|
}
|
|
194
239
|
}
|
|
195
240
|
else if (installing) {
|
package/dist/texts.js
CHANGED
|
@@ -62,6 +62,8 @@ export const SETUP_TOOL_DESCRIPTION = [
|
|
|
62
62
|
"Call it with install=false (the default) first and tell the user what is missing.",
|
|
63
63
|
"Only call it with install=true after the user agrees: it runs `brew install` for the missing packages",
|
|
64
64
|
"and downloads the speech model once. It never uninstalls or changes anything else.",
|
|
65
|
+
"kokoro=true (also only after the user agrees) installs the optional Kokoro voice: more natural than the built-in one,",
|
|
66
|
+
"runs on this Mac, about 1 GB on disk. Once installed it's used automatically; the built-in voice stays as the fallback.",
|
|
65
67
|
'If it reports INSTALLING, the install continues in the background — wait a minute and call it again to check.',
|
|
66
68
|
].join("\n");
|
|
67
69
|
export const SETUP_PROMPT = [
|
package/dist/voice.js
CHANGED
|
@@ -51,11 +51,14 @@ async function turn(textToSpeak, seconds, model, signal, onPhase) {
|
|
|
51
51
|
try {
|
|
52
52
|
const t0 = Date.now();
|
|
53
53
|
onPhase?.("speaking");
|
|
54
|
-
await speak(speech.text, signal);
|
|
54
|
+
const spoken = await speak(speech.text, signal);
|
|
55
55
|
const spoke = Date.now() - t0;
|
|
56
|
+
const fallback = fallbackNote(spoken);
|
|
57
|
+
if (fallback)
|
|
58
|
+
notes.push(fallback);
|
|
56
59
|
if (!model) {
|
|
57
60
|
const text = "(Spoken. The microphone was not opened, because listen was false.)";
|
|
58
|
-
return { ok: true, text, notes: [...notes,
|
|
61
|
+
return { ok: true, text, notes: [...notes, timingNote({ spokeMs: spoke, spoken })] };
|
|
59
62
|
}
|
|
60
63
|
await chime("start");
|
|
61
64
|
onPhase?.("listening");
|
|
@@ -64,7 +67,7 @@ async function turn(textToSpeak, seconds, model, signal, onPhase) {
|
|
|
64
67
|
void chime("stop");
|
|
65
68
|
const t1 = Date.now();
|
|
66
69
|
debug("listen:", heard);
|
|
67
|
-
const timing = (transcribedMs) => timingNote({ spokeMs: spoke, listenedMs: t1 - tListen, talkedSeconds: heard.speechSeconds, transcribedMs });
|
|
70
|
+
const timing = (transcribedMs) => timingNote({ spokeMs: spoke, spoken, listenedMs: t1 - tListen, talkedSeconds: heard.speechSeconds, transcribedMs });
|
|
68
71
|
if (heard.digitalSilence) {
|
|
69
72
|
resetWhichCache(); // if the user fixes it by installing SoX, the next turn picks it up
|
|
70
73
|
const viaFfmpeg = findRecorder()?.kind === "ffmpeg";
|
|
@@ -105,12 +108,23 @@ async function turn(textToSpeak, seconds, model, signal, onPhase) {
|
|
|
105
108
|
}
|
|
106
109
|
}
|
|
107
110
|
const secs = (ms) => `${(ms / 1000).toFixed(1)} s`;
|
|
111
|
+
/** Kokoro failures already reported in this session: say each once, not on every turn. */
|
|
112
|
+
const reportedFallbacks = new Set();
|
|
113
|
+
function fallbackNote(spoken) {
|
|
114
|
+
if (!spoken.fallback || reportedFallbacks.has(spoken.fallback))
|
|
115
|
+
return null;
|
|
116
|
+
reportedFallbacks.add(spoken.fallback);
|
|
117
|
+
return (`voice-mcp note: the Kokoro voice didn't work (${spoken.fallback}), so the built-in voice spoke instead. ` +
|
|
118
|
+
"Mention it to the user once; voice_setup shows what's wrong.");
|
|
119
|
+
}
|
|
108
120
|
/** One compact line per turn, so "why did that feel slow?" has an answer. */
|
|
109
121
|
export function timingNote(t) {
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
122
|
+
const k = t.spoken?.engine === "kokoro" && t.spoken.firstAudioMs !== undefined ? t.spoken : null;
|
|
123
|
+
const parts = [`spoke ${secs(t.spokeMs)}` + (k ? ` (Kokoro, first sound after ${secs(k.firstAudioMs)}${k.coldStart ? ", voice loaded" : ""})` : "")];
|
|
124
|
+
if (t.listenedMs !== undefined) {
|
|
125
|
+
const talked = t.talkedSeconds ?? 0;
|
|
126
|
+
parts.push(`listened ${secs(t.listenedMs)}` + (talked > 0 ? ` (user talked ${talked.toFixed(1)} s)` : " (no speech)"));
|
|
127
|
+
}
|
|
114
128
|
if (t.transcribedMs !== undefined)
|
|
115
129
|
parts.push(`transcribed ${secs(t.transcribedMs)}`);
|
|
116
130
|
return `voice-mcp timing: ${parts.join(" · ")}`;
|
package/dist/wer.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Word error rate: how far a transcript is from the words that were actually said.
|
|
3
|
+
* Used by `doctor` and the round-trip tests, so "it heard me right" is a number, not a feeling.
|
|
4
|
+
*/
|
|
5
|
+
const ONES = ["zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", "eleven", "twelve", "thirteen", "fourteen", "fifteen", "sixteen", "seventeen", "eighteen", "nineteen"];
|
|
6
|
+
const TENS = ["", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy", "eighty", "ninety"];
|
|
7
|
+
/** 0–999 as words ("42" → "forty two"), so "60" and "sixty" count as the same word. */
|
|
8
|
+
export function numberToWords(n) {
|
|
9
|
+
if (n < 20)
|
|
10
|
+
return ONES[n];
|
|
11
|
+
if (n < 100)
|
|
12
|
+
return TENS[Math.floor(n / 10)] + (n % 10 ? ` ${ONES[n % 10]}` : "");
|
|
13
|
+
return `${ONES[Math.floor(n / 100)]} hundred` + (n % 100 ? ` ${numberToWords(n % 100)}` : "");
|
|
14
|
+
}
|
|
15
|
+
/** Lower-case words without punctuation; "index.ts" → "index ts", "60" → "sixty". */
|
|
16
|
+
export function normalizeWords(text) {
|
|
17
|
+
return text
|
|
18
|
+
.toLowerCase()
|
|
19
|
+
.replace(/\[[^\]]*\]|\([^)]*\)/g, " ") // whisper's [BLANK_AUDIO], (music) …
|
|
20
|
+
.replace(/(\d)[,.](?=\d{3}\b)/g, "$1") // 1,000 → 1000
|
|
21
|
+
.replace(/[^\p{L}\p{N}']+/gu, " ")
|
|
22
|
+
.replace(/'/g, "")
|
|
23
|
+
.split(/\s+/)
|
|
24
|
+
.filter(Boolean)
|
|
25
|
+
.flatMap((w) => (/^\d{1,3}$/.test(w) ? numberToWords(Number(w)).split(" ") : [w]));
|
|
26
|
+
}
|
|
27
|
+
/** Word-level edit distance between what was said (reference) and what was heard (hypothesis). */
|
|
28
|
+
export function wordErrorRate(reference, hypothesis) {
|
|
29
|
+
const ref = normalizeWords(reference);
|
|
30
|
+
const hyp = normalizeWords(hypothesis);
|
|
31
|
+
let prev = Array.from({ length: hyp.length + 1 }, (_, j) => j);
|
|
32
|
+
for (let i = 1; i <= ref.length; i++) {
|
|
33
|
+
const cur = [i];
|
|
34
|
+
for (let j = 1; j <= hyp.length; j++) {
|
|
35
|
+
cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (ref[i - 1] === hyp[j - 1] ? 0 : 1));
|
|
36
|
+
}
|
|
37
|
+
prev = cur;
|
|
38
|
+
}
|
|
39
|
+
const errors = prev[hyp.length];
|
|
40
|
+
return { wer: ref.length ? errors / ref.length : hyp.length ? 1 : 0, errors, words: ref.length };
|
|
41
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mac-voice-mcp",
|
|
3
3
|
"mcpName": "io.github.jeet0007/mac-voice-mcp",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.4.0",
|
|
5
5
|
"description": "Talk with Claude out loud on your Mac: speaks with macOS `say`, listens for one natural conversational turn, and transcribes on-device with whisper.cpp. An MCP server.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -23,6 +23,8 @@
|
|
|
23
23
|
"test": "npm run build && node --test test/*.test.mjs",
|
|
24
24
|
"setup": "node dist/index.js setup",
|
|
25
25
|
"test:voice": "node dist/index.js test",
|
|
26
|
+
"doctor": "node dist/index.js doctor",
|
|
27
|
+
"dev:plugin": "node scripts/dev-plugin.mjs",
|
|
26
28
|
"inspect": "npx -y @modelcontextprotocol/inspector node dist/index.js",
|
|
27
29
|
"prepare": "npm run build",
|
|
28
30
|
"prepublishOnly": "npm test",
|