mac-voice-mcp 0.3.0 → 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/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,17 +76,34 @@ 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(choice.canUpgrade
75
- ? { label: "Voice", status: "optional", detail: `${choice.label} — ${VOICE_UPGRADE_HINT}` }
76
- : { label: "Voice", status: "ok", detail: choice.label });
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
- checks.push(rec
80
- ? { label: "Recorder", status: "ok", detail: describeRecorder(rec) }
81
- : { label: "Recorder", status: brewing ? "installing" : "missing", detail: "SoX is not installed", brew: IS_WIN ? undefined : "sox" });
92
+ if (!rec) {
93
+ checks.push({ label: "Recorder", status: brewing ? "installing" : "missing", detail: "SoX is not installed", brew: IS_WIN ? undefined : "sox" });
94
+ }
95
+ else if (rec.kind === "ffmpeg" && CONFIG.recorder === "auto") {
96
+ // Works, but it's the fragile path: recommend SoX, which is what the turn-taking is tuned on.
97
+ checks.push({
98
+ label: "Recorder",
99
+ status: brewing ? "installing" : "optional",
100
+ detail: `${describeRecorder(rec)}, as a fallback. SoX is recommended: it's more reliable with Bluetooth headsets and other audio devices`,
101
+ brew: "sox",
102
+ });
103
+ }
104
+ else {
105
+ checks.push({ label: "Recorder", status: "ok", detail: describeRecorder(rec) });
106
+ }
82
107
  const cli = findWhisperCli();
83
108
  const server = findWhisperServer();
84
109
  if (cli || server) {
@@ -125,52 +150,91 @@ async function checkRequirements() {
125
150
  }
126
151
  return checks;
127
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
+ }
128
178
  // --- The flow ------------------------------------------------------------------------------
129
- /**
130
- * @param install true only after the user agreed: brew-install missing formulae, download the model if absent.
131
- * @param onProgress optional heartbeat while waiting (used for MCP progress notifications).
132
- */
133
- export async function runSetupFlow(install, onProgress) {
179
+ export async function runSetupFlow(opts = {}) {
180
+ const { install = false, onProgress } = opts;
181
+ resetWhichCache(); // see anything installed since the last check (e.g. `brew install sox` in a terminal)
134
182
  let checks = await checkRequirements();
135
183
  if (install) {
136
- const formulae = [...new Set(checks.filter((c) => c.status === "missing" && c.brew).map((c) => c.brew))];
184
+ // Missing pieces, plus recommended ones (SoX when only ffmpeg is there): the user agreed to install.
185
+ const formulae = [...new Set(checks.filter((c) => (c.status === "missing" || c.status === "optional") && c.brew).map((c) => c.brew))];
137
186
  const brew = findBrew();
138
187
  if (formulae.length && brew && !running(brewJob))
139
188
  brewJob = startBrewInstall(brew, formulae);
140
189
  if (checks.some((c) => c.model && c.status === "missing") && !running(modelJob))
141
190
  modelJob = startModelDownload();
142
191
  }
192
+ if (opts.kokoro && !running(kokoroJob) && !(isKokoroInstalled() && isKokoroModelPresent()))
193
+ kokoroJob = startKokoroInstall();
143
194
  // Wait (bounded) for anything in flight, with a heartbeat.
144
- const jobs = [brewJob, modelJob].filter(running);
195
+ const jobs = [brewJob, modelJob, kokoroJob].filter(running);
145
196
  if (jobs.length) {
146
197
  const deadline = Date.now() + INSTALL_WAIT_MS;
147
198
  while (jobs.some((j) => !j.done) && Date.now() < deadline) {
148
- onProgress?.(jobs.filter((j) => !j.done).map((j) => `${j.label} (${elapsed(j)})`).join("; "));
199
+ onProgress?.(jobs
200
+ .filter((j) => !j.done)
201
+ .map((j) => `${j.label} (${j === kokoroJob ? `${kokoroProgress}, ` : ""}${elapsed(j)})`)
202
+ .join("; "));
149
203
  await Promise.race([Promise.all(jobs.map((j) => j.promise)), sleep(5000)]);
150
204
  }
151
205
  checks = await checkRequirements();
152
206
  }
153
- const installing = running(brewJob) || running(modelJob);
207
+ const installing = running(brewJob) || running(modelJob) || running(kokoroJob);
154
208
  const ready = checks.every((c) => c.status === "ok" || c.status === "optional");
155
209
  const icon = { ok: "✔", missing: "✘", optional: "•", installing: "…" };
156
210
  const done = finishedMessages.splice(0);
157
211
  const lines = [
158
212
  `voice-mcp setup — ${ready ? "READY" : installing ? "INSTALLING" : "NOT READY"}`,
159
213
  "",
160
- ...checks.map((c) => `${icon[c.status]} ${c.label}: ${c.detail}${c.brew && c.status === "missing" ? ` → brew install ${c.brew}` : ""}`),
214
+ ...checks.map((c) => `${icon[c.status]} ${c.label}: ${c.detail}${c.brew && c.status !== "ok" && c.status !== "installing" ? ` → brew install ${c.brew}` : ""}`),
161
215
  IS_MAC ? "• Microphone: macOS asks for permission the first time speak_and_listen listens — click Allow." : "",
162
216
  ];
163
217
  if (done.length)
164
218
  lines.push("", "What was done:", ...done.map((d) => `- ${d}`));
165
- if (install && !done.length && !installing && ready)
219
+ if ((install || opts.kokoro) && !done.length && !installing && ready)
166
220
  lines.push("", "Nothing to install — everything was already present.");
167
221
  const toInstall = [...new Set(checks.filter((c) => c.status === "missing" && c.brew).map((c) => c.brew))];
168
222
  const needsModel = checks.some((c) => c.model && c.status === "missing");
169
223
  lines.push("");
170
224
  if (ready) {
171
225
  lines.push("Next: everything is in place — speak_and_listen is ready to use.");
226
+ const recommended = [...new Set(checks.filter((c) => c.status === "optional" && c.brew).map((c) => c.brew))];
227
+ if (recommended.length && !install && findBrew()) {
228
+ lines.push(`Recommended: ask the user whether to brew install ${recommended.join(" ")} (see • above). ` +
229
+ "Only if they agree, call voice_setup with install=true.");
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
+ }
172
235
  if (checks.some((c) => c.label === "Voice" && c.status === "optional")) {
173
- lines.push("Mention the optional voice tip (•) to the user once — it makes the voice sound far more natural. Nothing else to do.");
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.");
174
238
  }
175
239
  }
176
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
@@ -6,7 +6,7 @@ import { chime, findRecorder, listenForTurn, MIC_PERMISSION_HINT, RECORDER_MISSI
6
6
  import { CONFIG, debug, DEFAULT_LISTEN_SECONDS, MAX_LISTEN_SECONDS, MAX_SPEAK_CHARS } from "./config.js";
7
7
  import { acquireMicLock, MicBusyError } from "./lock.js";
8
8
  import { ensureModel } from "./model.js";
9
- import { SetupError } from "./proc.js";
9
+ import { resetWhichCache, SetupError } from "./proc.js";
10
10
  import { prepareSpeech } from "./speech-text.js";
11
11
  import { findWhisperCli, findWhisperServer, prewarm, STT_MISSING, transcribe } from "./stt.js";
12
12
  /** Everything speak_and_listen needs, checked before saying a word. Never installs anything. */
@@ -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, `voice-mcp timing: spoke ${secs(spoke)}`] };
61
+ return { ok: true, text, notes: [...notes, timingNote({ spokeMs: spoke, spoken })] };
59
62
  }
60
63
  await chime("start");
61
64
  onPhase?.("listening");
@@ -64,11 +67,18 @@ 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) {
72
+ resetWhichCache(); // if the user fixes it by installing SoX, the next turn picks it up
73
+ const viaFfmpeg = findRecorder()?.kind === "ffmpeg";
69
74
  return {
70
75
  ok: false,
71
- text: `The microphone returned pure digital silence, which usually means microphone access is blocked. ${MIC_PERMISSION_HINT}`,
76
+ text: viaFfmpeg
77
+ ? "The microphone returned pure digital silence. voice-mcp is recording with ffmpeg because SoX isn't installed, " +
78
+ "and ffmpeg may be using a silent or wrong input device (for example after connecting Bluetooth headphones). " +
79
+ "Suggest `brew install sox` (or call voice_setup), then try again. If that doesn't help: " +
80
+ MIC_PERMISSION_HINT
81
+ : `The microphone returned pure digital silence, which usually means microphone access is blocked. ${MIC_PERMISSION_HINT}`,
72
82
  notes,
73
83
  };
74
84
  }
@@ -98,12 +108,23 @@ async function turn(textToSpeak, seconds, model, signal, onPhase) {
98
108
  }
99
109
  }
100
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
+ }
101
120
  /** One compact line per turn, so "why did that feel slow?" has an answer. */
102
121
  export function timingNote(t) {
103
- const parts = [
104
- `spoke ${secs(t.spokeMs)}`,
105
- `listened ${secs(t.listenedMs)}` + (t.talkedSeconds > 0 ? ` (user talked ${t.talkedSeconds.toFixed(1)} s)` : " (no speech)"),
106
- ];
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
+ }
107
128
  if (t.transcribedMs !== undefined)
108
129
  parts.push(`transcribed ${secs(t.transcribedMs)}`);
109
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.3.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",