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/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(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
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
- * @param install true only after the user agreed: brew-install missing formulae, download the model if absent.
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.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("; "));
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. 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.");
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, `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,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 parts = [
111
- `spoke ${secs(t.spokeMs)}`,
112
- `listened ${secs(t.listenedMs)}` + (t.talkedSeconds > 0 ? ` (user talked ${t.talkedSeconds.toFixed(1)} s)` : " (no speech)"),
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.3.1",
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",