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 CHANGED
@@ -5,6 +5,24 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.4.0] - 2026-10-06
9
+
10
+ ### Added
11
+ - **An optional natural voice: Kokoro.** [Kokoro-82M](https://huggingface.co/hexgrad/Kokoro-82M) is a small neural voice that runs on your Mac and sounds much closer to a person than `say`.
12
+ - **Install it only if you want it:** `voice_setup` with `kokoro=true` (Claude asks first), or `npx mac-voice-mcp setup --kokoro`. It installs `kokoro-js@1.2.1` with npm into `~/.cache/mac-voice-mcp/kokoro/` and downloads the model once, about 1 GB on disk. Nothing is bundled, and speaking never downloads anything.
13
+ - **Once installed it's used automatically**, with the `af_heart` voice. It runs in its own warm process and speaks sentence by sentence, so the first sentence plays while the rest is generated. The timing line shows how soon the first sound came.
14
+ - **It can't leave you without a voice.** If Kokoro fails, the built-in voice says whatever hadn't been said yet, and Claude is told once.
15
+ - **Settings:** `VOICE_MCP_TTS` (`auto`, `say` or `kokoro`), `VOICE_MCP_KOKORO_VOICE`, `VOICE_MCP_KOKORO_SPEED`, `VOICE_MCP_KOKORO_DTYPE` and `VOICE_MCP_KOKORO_DIR`.
16
+ - **`doctor` and the CI round trip cover Kokoro too:** whisper must understand it, and the first sound must come quickly.
17
+ - **`doctor`: an objective self-check** (`npx mac-voice-mcp doctor`). It checks the whole pipeline on your Mac and marks each stage PASS, WARN or FAIL against fixed limits:
18
+ - **Setup:** everything is installed.
19
+ - **Speech → text:** the voice speaks a known sentence into a file, and whisper must get the words right.
20
+ - **Speaker → mic:** the same sentence is played aloud and recorded. It catches mic permission problems, the wrong input device and headphones.
21
+
22
+ Reports are saved as JSON under `~/.cache/mac-voice-mcp/doctor/`. Use `--no-loopback` to skip the speakers, and `--json` for machine-readable output.
23
+ - **A "Speech round trip" CI job on a real Mac.** It installs everything with our own `setup --install`, runs `doctor`, then speaks known sentences (everyday and developer jargon) with the real voice and transcribes them with the real whisper.cpp. The build fails on too many wrong words or slow transcription.
24
+ - **`npm run dev:plugin`** loads this checkout into Claude Code as a separate "mac-voice-mcp-dev" plugin. It runs the local build with `node`, with no npx and no clash with the installed plugin.
25
+
8
26
  ## [0.3.1] - 2026-09-29
9
27
 
10
28
  ### Fixed
@@ -78,7 +96,10 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
78
96
  - `voice_setup`: checks first, installs only what's missing, and only after you agree.
79
97
  - The `setup` and `voice_mode` prompts.
80
98
 
81
- [Unreleased]: https://github.com/jeet0007/mac-voice-mcp/compare/v0.2.0...HEAD
99
+ [Unreleased]: https://github.com/jeet0007/mac-voice-mcp/compare/v0.4.0...HEAD
100
+ [0.4.0]: https://github.com/jeet0007/mac-voice-mcp/compare/v0.3.1...v0.4.0
101
+ [0.3.1]: https://github.com/jeet0007/mac-voice-mcp/compare/v0.3.0...v0.3.1
102
+ [0.3.0]: https://github.com/jeet0007/mac-voice-mcp/compare/v0.2.0...v0.3.0
82
103
  [0.2.0]: https://github.com/jeet0007/mac-voice-mcp/compare/v0.1.1...v0.2.0
83
104
  [0.1.1]: https://github.com/jeet0007/mac-voice-mcp/compare/119a589...v0.1.1
84
105
  [0.1.0]: https://github.com/jeet0007/mac-voice-mcp/tree/119a589
package/README.md CHANGED
@@ -135,6 +135,14 @@ Setup checks what's already there before it changes anything:
135
135
 
136
136
  **Get a better voice (recommended).** macOS includes free Premium voices that sound far more natural than the default. Open **System Settings → Accessibility → Spoken Content → System Voice → Manage Voices…**, and download one, for example English → *Ava (Premium)* or *Zoe (Premium)*. The next voice turn uses it automatically. To choose a specific voice, or keep the system voice, see `VOICE_MCP_VOICE` under [Configuration](#configuration).
137
137
 
138
+ **Or try the Kokoro voice (optional).** [Kokoro](https://huggingface.co/hexgrad/Kokoro-82M) is a small neural voice that sounds close to a person and runs entirely on your Mac. Ask Claude to *"install the Kokoro voice"*, or run `npx -y mac-voice-mcp@latest setup --kokoro`.
139
+
140
+ - **What it installs:** [kokoro-js](https://github.com/hexgrad/kokoro) with npm into `~/.cache/mac-voice-mcp/kokoro/`, and the model from Hugging Face, once. That's about 1 GB on disk, and nothing is bundled with this package.
141
+ - **How it's used:** once it's installed, every turn uses it, starting with `af_heart`. Pick another voice with `VOICE_MCP_KOKORO_VOICE` (see [Configuration](#configuration)). It speaks sentence by sentence, so it starts talking before the whole reply is generated. Each turn's timing line shows how soon the first sound came.
142
+ - **It can't leave you without a voice.** If Kokoro fails for any reason, the built-in voice takes over mid-sentence, and Claude tells you once.
143
+ - **To stop using it:** set `VOICE_MCP_TTS=say`, or delete `~/.cache/mac-voice-mcp/kokoro/`.
144
+ - **Limits:** English only (American and British voices). It speaks with its own pronunciation rules, which handle common developer words (JSON, `index.ts`, version numbers).
145
+
138
146
  ### Allow voice turns without prompts
139
147
 
140
148
  By default, Claude Code asks for approval every time Claude wants to speak, which breaks the flow of a conversation. To allow voice turns, add the tool to the `permissions.allow` list in `~/.claude/settings.json`. Use the name that matches how you installed it:
@@ -222,7 +230,12 @@ Everything is optional. Set these in your client config's `"env": { … }` block
222
230
  | Variable | Default | |
223
231
  |---|---|---|
224
232
  | `VOICE_MCP_VOICE` | most natural installed | Unset: the best Premium or Enhanced voice installed for the language, else the system voice. Set a name, e.g. `Ava (Premium)`, `Daniel`, `Kanya` (list them with `say -v '?'`), or `default` to always use the system voice. |
225
- | `VOICE_MCP_RATE` | system rate | Words per minute, e.g. `200`. |
233
+ | `VOICE_MCP_RATE` | system rate | Words per minute, e.g. `200` (macOS voices). |
234
+ | `VOICE_MCP_TTS` | `auto` | `auto`: the [Kokoro voice](#then-set-up-and-allow-the-mic) once it's installed, else the built-in voice. `say`: always the built-in voice. `kokoro`: Kokoro, and setup offers to install it. |
235
+ | `VOICE_MCP_KOKORO_VOICE` | `af_heart` | Kokoro voice. American English starts with `a`, British with `b`, e.g. `af_bella`, `am_michael`, `bf_emma`, `bm_george`. |
236
+ | `VOICE_MCP_KOKORO_SPEED` | `1` | Kokoro speaking speed, `0.5` to `2`. |
237
+ | `VOICE_MCP_KOKORO_DTYPE` | `fp32` | `q8`: a smaller model (~90 MB instead of ~330 MB) that's about half as fast. |
238
+ | `VOICE_MCP_KOKORO_DIR` | `~/.cache/mac-voice-mcp/kokoro` | Where the Kokoro voice is installed. |
226
239
  | `VOICE_MCP_MAX_SPEAK_WORDS` | `120` | Longer text is cut at a sentence boundary ("the rest is on screen"). |
227
240
  | `VOICE_MCP_CHIME` | `1` | Set to `0` to turn off the mic open/close sounds. |
228
241
  | `VOICE_MCP_LOCK_WAIT_SECONDS` | `120` | How long a turn waits while another session on this Mac is using the mic. |
@@ -272,6 +285,7 @@ Everything is optional. Set these in your client config's `"env": { … }` block
272
285
 
273
286
  | Symptom | Fix |
274
287
  |---|---|
288
+ | Not sure what's wrong | Run `npx -y mac-voice-mcp@latest doctor`. It checks setup, the voice and transcription, and your speakers and mic, and says which part fails. |
275
289
  | "voice-mcp is not set up yet" | Ask Claude to *set up voice*, or run `npx -y mac-voice-mcp@latest setup`. |
276
290
  | "microphone returned pure digital silence" | macOS is blocking the mic for the host app. Go to **System Settings → Privacy & Security → Microphone**, enable Claude / Cursor / your terminal, then restart that app. If the message says it's recording with ffmpeg, the input device is the likelier cause: run `brew install sox`. |
277
291
  | No permission prompt ever appears | Run `tccutil reset Microphone <bundle id>` and restart the app. Running `test` in Terminal only gives permission to Terminal, not to Claude Desktop. |
@@ -280,7 +294,8 @@ Everything is optional. Set these in your client config's `"env": { … }` block
280
294
  | It hears its own voice | Use headphones, or turn the speaker volume down. It only listens after it finishes speaking, but echo can linger. |
281
295
  | "Homebrew: not installed" | Install it from [brew.sh](https://brew.sh). It needs your password, so it can't run from Claude. Then run setup again. |
282
296
  | It garbles names or jargon | Set `VOICE_MCP_WHISPER_PROMPT="Priya, Postgres, Kubernetes"`, or switch to `small.en`. |
283
- | The voice sounds robotic | Download a Premium voice (see [Get a better voice](#then-set-up-and-allow-the-mic)). It's used automatically. |
297
+ | The voice sounds robotic | Download a Premium voice, or install the Kokoro voice (see [Get a better voice](#then-set-up-and-allow-the-mic)). Either is used automatically. |
298
+ | "The Kokoro voice didn't work" | The built-in voice spoke instead. Ask Claude to *check voice setup*, or run `npx -y mac-voice-mcp@latest setup`. To reinstall it, delete `~/.cache/mac-voice-mcp/kokoro/` and run `setup --kokoro`. |
284
299
  | It asks for approval every turn | Add the tool to Claude Code's allow list: see [Allow voice turns without prompts](#allow-voice-turns-without-prompts). |
285
300
  | "Another voice session on this Mac…" | Another Claude window, Claude Desktop or Cursor held the speaker and mic for over 2 minutes, which means one very long turn. End that conversation, then try again. |
286
301
  | Claude keeps answering by voice after I'm done | Type anything, or say "stop voice mode". To switch the plugin's hook off entirely, see [Voice mode](#voice-mode). |
@@ -295,9 +310,10 @@ Everything is optional. Set these in your client config's `"env": { … }` block
295
310
 
296
311
  ## Privacy and safety
297
312
 
298
- - **Audio stays on your machine.** Recordings go to a temporary file that's deleted after each turn. The only network use is the one-time model download from Hugging Face.
313
+ - **Audio stays on your machine.** Recordings go to a temporary file that's deleted after each turn. The only network use is the one-time model download from Hugging Face, plus npm and Hugging Face once more if you install the Kokoro voice. Speaking never downloads anything.
299
314
  - **The warm whisper server is local only.** It listens on `127.0.0.1` on a random port, and stops when idle or when this server exits.
300
- - **Setup can only install known packages.** Its install list is fixed in the code (`sox`, `whisper-cpp`), so nothing Claude says can make it install anything else. It never uninstalls or modifies other software.
315
+ - **Setup can only install known packages.** Its install list is fixed in the code (`sox`, `whisper-cpp`, and `kokoro-js@1.2.1` for the optional voice), so nothing Claude says can make it install anything else. It never uninstalls or modifies other software.
316
+ - **Licenses of the optional Kokoro voice.** The Kokoro model and kokoro-js are Apache-2.0. kokoro-js turns text into sounds with a WebAssembly build of espeak-ng (GPL-3.0), through the `phonemizer` package. None of this is part of mac-voice-mcp (MIT). It's installed on your Mac only if you ask for it.
301
317
 
302
318
  ## Security
303
319
 
@@ -316,6 +332,8 @@ npm test # build + unit tests, end-to-end tests over MCP with stub
316
332
  npm run audit # known-vulnerability and signature checks on dependencies
317
333
  npm run setup # check what's installed; offers to install what's missing
318
334
  npm run test:voice # one real speak → listen → transcribe turn
335
+ npm run doctor # objective self-check on this Mac: PASS / WARN / FAIL per stage, JSON report
336
+ npm run dev:plugin # try this checkout in Claude Code as the "mac-voice-mcp-dev" plugin
319
337
  npm run inspect # MCP Inspector
320
338
  ```
321
339
 
@@ -325,6 +343,8 @@ npm run inspect # MCP Inspector
325
343
  | `speech-text.ts` | Rewriting screen text for speech, cleaning up transcripts (pure, unit-tested) |
326
344
  | `endpointer.ts` | Turn-taking voice-activity detection (pure, unit-tested) |
327
345
  | `audio.ts` | Text-to-speech, chimes, streaming mic capture |
346
+ | `kokoro.ts`, `kokoro-worker.ts` | The optional Kokoro voice: install, a warm worker process, sentence-by-sentence playback, fallback |
347
+ | `kokoro-text.ts`, `pcm.ts` | Pronunciation fixes and sentence chunks for Kokoro, PCM conversion (pure, unit-tested) |
328
348
  | `model.ts` | Finding, symlinking or downloading the model |
329
349
  | `stt.ts` | The warm `whisper-server` with orphan guard, and the `whisper-cli` fallback |
330
350
  | `setup.ts` | Requirement checks and consent-based background installs |
@@ -334,7 +354,13 @@ npm run inspect # MCP Inspector
334
354
 
335
355
  The package installs two commands: `mac-voice-mcp` (the one `npx -y mac-voice-mcp` runs), and `voice-mcp`.
336
356
 
337
- **Testing the plugin: don't start Claude Code inside this repo.** In this folder, `npx mac-voice-mcp@<this version>` finds the checkout itself instead of downloading the package, can't run it, and the server fails with `CONNECTION_CLOSED`. Start `claude` in any other folder. To try unreleased plugin files (skills, hooks), swap the marketplace to your checkout: run `/plugin marketplace remove mac-voice-mcp`, then `/plugin marketplace add /path/to/checkout`, and install at user scope. The server still comes from npm, so test server changes with `npm test` and `npm run test:voice`.
357
+ **Testing changes.** Three layers; none of them depend on anyone's ears.
358
+
359
+ 1. **`npm test`** runs everywhere, with stub binaries. It covers the MCP tools, turn-taking, setup, the mic lock, the plugin hook and the release metadata.
360
+ 2. **The "Speech round trip" CI job** runs on a real Mac. Our own `setup --install` puts in SoX, whisper.cpp and the model. Then `doctor --no-loopback` runs, and `test/roundtrip.test.mjs` speaks known sentences with the real voice and transcribes them with the real whisper.cpp. The build fails if too many words come back wrong or transcription is too slow. The same job then installs the Kokoro voice with `setup --kokoro` and repeats the round trip with it, which also checks how soon Kokoro starts talking. GitHub's Macs have only basic voices and no GPU for whisper.cpp, so the limits there are looser: it guards against breakage, and doctor on a real Mac is the quality bar. The numbers are kept as a build artifact.
361
+ 3. **`npm run doctor`** on your own Mac adds the one thing CI can't test, your speakers and microphone. It plays a sentence through the speakers, records it and transcribes it. Each stage gets PASS, WARN or FAIL against fixed limits, and the report is saved under `~/.cache/mac-voice-mcp/doctor/`.
362
+
363
+ **Trying the plugin from a checkout:** run `npm run dev:plugin`, then the `claude --plugin-dir …` command it prints. This loads a throwaway plugin, "mac-voice-mcp-dev", that runs this checkout's build with `node`. Its own name means it doesn't clash with an installed mac-voice-mcp. It also works inside this repo, where `npx mac-voice-mcp@<this version>` would find the checkout instead of the package and fail with `CONNECTION_CLOSED`.
338
364
 
339
365
  ### Releasing
340
366
 
package/dist/audio.js CHANGED
@@ -2,8 +2,9 @@
2
2
  import { spawn } from "node:child_process";
3
3
  import { existsSync } from "node:fs";
4
4
  import { writeFile } from "node:fs/promises";
5
- import { CONFIG, debug, IS_MAC, IS_WIN } from "./config.js";
5
+ import { CONFIG, debug, IS_MAC, IS_WIN, log } from "./config.js";
6
6
  import { Endpointer, FRAME_BYTES, wavHeader } from "./endpointer.js";
7
+ import { KokoroError, kokoroEnabled, speakKokoro, synthesizeKokoroToFile } from "./kokoro.js";
7
8
  import { activeChildren, CancelledError, run, SetupError, tail, which } from "./proc.js";
8
9
  // ---------------------------------------------------------------------------
9
10
  // Text-to-speech
@@ -71,9 +72,33 @@ export function chooseVoice() {
71
72
  })();
72
73
  return voiceChoice;
73
74
  }
75
+ /**
76
+ * Speak `text` out loud: with the Kokoro voice when it's installed (or VOICE_MCP_TTS=kokoro),
77
+ * otherwise the built-in voice. If Kokoro fails, the built-in voice says whatever it hadn't said yet.
78
+ */
74
79
  export async function speak(text, signal) {
75
80
  if (!text)
76
- return;
81
+ return { engine: "built-in" };
82
+ if (kokoroEnabled()) {
83
+ try {
84
+ return { engine: "kokoro", ...(await speakKokoro(text, signal)) };
85
+ }
86
+ catch (err) {
87
+ if (err instanceof CancelledError)
88
+ throw err;
89
+ const reason = err instanceof Error ? err.message : String(err);
90
+ log(`The Kokoro voice failed, so the built-in voice is speaking instead: ${reason}`);
91
+ const rest = err instanceof KokoroError ? err.unspoken.join(" ") : text;
92
+ if (rest)
93
+ await speakBuiltIn(rest, signal);
94
+ return { engine: "built-in", fallback: reason };
95
+ }
96
+ }
97
+ await speakBuiltIn(text, signal);
98
+ return { engine: "built-in" };
99
+ }
100
+ /** macOS `say`, espeak-ng, or Windows SAPI. */
101
+ async function speakBuiltIn(text, signal) {
77
102
  const bin = findTts();
78
103
  if (!bin)
79
104
  throw new SetupError("No text-to-speech engine found. Install espeak-ng (e.g. `sudo apt install espeak-ng`).");
@@ -103,6 +128,32 @@ export async function speak(text, signal) {
103
128
  throw new Error(`Text-to-speech failed (exit ${result.code}): ${tail(result.stderr) || "no output"}`);
104
129
  }
105
130
  }
131
+ /**
132
+ * Speak `text` into a 16 kHz mono 16-bit WAV instead of the speakers, with the same voice
133
+ * `speak` uses. For `doctor` and the round-trip tests. A Kokoro failure is an error here, not a fallback.
134
+ */
135
+ export async function synthesizeToFile(text, outFile) {
136
+ if (kokoroEnabled())
137
+ return { engine: "kokoro", ...(await synthesizeKokoroToFile(text, outFile)) };
138
+ const bin = findTts();
139
+ if (!bin)
140
+ throw new SetupError("No text-to-speech engine found.");
141
+ let result;
142
+ if (IS_MAC) {
143
+ const { voice } = await chooseVoice();
144
+ const args = [...(voice ? ["-v", voice] : []), "-o", outFile, "--file-format=WAVE", "--data-format=LEI16@16000", "-f", "-"];
145
+ result = await run(bin, args, { input: text, timeoutMs: 60_000 });
146
+ }
147
+ else if (/espeak/.test(bin)) {
148
+ result = await run(bin, ["-w", outFile, "--stdin"], { input: text, timeoutMs: 60_000 });
149
+ }
150
+ else {
151
+ throw new SetupError("Writing speech to a file needs macOS `say` or espeak-ng.");
152
+ }
153
+ if (result.code !== 0)
154
+ throw new Error(`Text-to-speech to file failed (exit ${result.code}): ${tail(result.stderr) || "no output"}`);
155
+ return { engine: "built-in" };
156
+ }
106
157
  /** A short, quiet cue that the mic just opened ("start") or closed ("stop"). macOS only. */
107
158
  export async function chime(kind) {
108
159
  if (!CONFIG.chime || !IS_MAC)
@@ -152,6 +203,63 @@ function recorderArgs(r) {
152
203
  "-ac", "1", "-ar", "16000", "-f", "s16le", "-",
153
204
  ];
154
205
  }
206
+ /**
207
+ * Record exactly `seconds` from the microphone into `outFile` (16 kHz mono WAV), no turn-taking.
208
+ * `onStarted` fires once audio is actually flowing, so a caller can start playback in sync.
209
+ */
210
+ export async function recordForSeconds(seconds, outFile, onStarted) {
211
+ const recorder = findRecorder();
212
+ if (!recorder)
213
+ throw new SetupError(RECORDER_MISSING);
214
+ const chunks = [];
215
+ let bytes = 0;
216
+ let stderr = "";
217
+ const wanted = Math.round(seconds * 16000) * 2;
218
+ const child = spawn(recorder.bin, recorderArgs(recorder), { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
219
+ activeChildren.add(child);
220
+ await new Promise((resolve, reject) => {
221
+ const stop = () => child.exitCode === null && child.kill("SIGTERM");
222
+ const safety = setTimeout(stop, (seconds + 10) * 1000);
223
+ child.stderr.setEncoding("utf8").on("data", (d) => (stderr = (stderr + d).slice(-4000)));
224
+ child.stdout.on("data", (chunk) => {
225
+ if (bytes === 0)
226
+ onStarted?.();
227
+ if (bytes >= wanted)
228
+ return;
229
+ chunks.push(chunk);
230
+ bytes += chunk.length;
231
+ if (bytes >= wanted)
232
+ stop();
233
+ });
234
+ child.on("error", (e) => {
235
+ clearTimeout(safety);
236
+ activeChildren.delete(child);
237
+ reject(e);
238
+ });
239
+ child.on("close", () => {
240
+ clearTimeout(safety);
241
+ activeChildren.delete(child);
242
+ resolve();
243
+ });
244
+ });
245
+ const pcm = Buffer.concat(chunks).subarray(0, wanted - (wanted % 2));
246
+ if (!pcm.length)
247
+ throw new Error(`Recording failed (${describeRecorder(recorder)}). ${tail(stderr)}`.trim());
248
+ await writeFile(outFile, Buffer.concat([wavHeader(pcm.length), pcm]));
249
+ let peak = 0;
250
+ let anyNonZero = false;
251
+ for (let off = 0; off + FRAME_BYTES <= pcm.length; off += FRAME_BYTES) {
252
+ let sum = 0;
253
+ for (let i = off; i < off + FRAME_BYTES; i += 2) {
254
+ const v = pcm.readInt16LE(i);
255
+ if (v !== 0)
256
+ anyNonZero = true;
257
+ sum += v * v;
258
+ }
259
+ peak = Math.max(peak, sum / (FRAME_BYTES / 2));
260
+ }
261
+ return { digitalSilence: !anyNonZero, peakDb: peak > 0 ? 10 * Math.log10(peak / 32768 ** 2) : -Infinity };
262
+ }
155
263
  /**
156
264
  * Listen for one conversational turn and write it to `outFile` (16 kHz mono WAV).
157
265
  * Streams raw PCM from the recorder, runs the endpointer on it live, and stops the
package/dist/config.js CHANGED
@@ -36,6 +36,21 @@ export const CONFIG = {
36
36
  /** How long a voice turn waits for another session to finish with the mic. */
37
37
  lockWaitSeconds: Math.max(1, envNum("VOICE_MCP_LOCK_WAIT_SECONDS", 120)),
38
38
  // --- Speaking
39
+ /**
40
+ * Which voice engine: auto (default: the Kokoro voice once it's installed, else the built-in one) |
41
+ * say (always the built-in voice: macOS `say`, espeak-ng elsewhere) | kokoro (Kokoro; voice_setup offers to install it).
42
+ */
43
+ tts: (["say", "kokoro"].includes(process.env.VOICE_MCP_TTS?.trim().toLowerCase() ?? "")
44
+ ? process.env.VOICE_MCP_TTS.trim().toLowerCase()
45
+ : "auto"),
46
+ /** Kokoro voice, American (a…) or British (b…) English: af_heart (default), af_bella, am_michael, bf_emma, … */
47
+ kokoroVoice: process.env.VOICE_MCP_KOKORO_VOICE?.trim() || "af_heart",
48
+ /** Kokoro speaking speed (1 = normal, 0.5–2). */
49
+ kokoroSpeed: Math.min(2, Math.max(0.5, envNum("VOICE_MCP_KOKORO_SPEED", 1))),
50
+ /** Kokoro model precision: fp32 (default: about twice as fast on a CPU, ~330 MB) or q8 (~90 MB, slower). */
51
+ kokoroDtype: (process.env.VOICE_MCP_KOKORO_DTYPE?.trim().toLowerCase() === "q8" ? "q8" : "fp32"),
52
+ /** Where the optional Kokoro voice (kokoro-js and its model) is installed. */
53
+ kokoroDir: process.env.VOICE_MCP_KOKORO_DIR?.trim() || path.join(CACHE_ROOT, "kokoro"),
39
54
  /**
40
55
  * macOS voice name, e.g. "Ava (Premium)" (`say -v '?'` lists them). Unset: the most natural
41
56
  * installed voice for the language (Premium, then Enhanced), else the system voice. "default": always the system voice.
package/dist/doctor.js ADDED
@@ -0,0 +1,185 @@
1
+ /**
2
+ * `doctor`: an objective self-check of the whole voice pipeline on this Mac.
3
+ *
4
+ * Every check ends in PASS / WARN / FAIL against fixed limits, so "does it work?" is answered by
5
+ * numbers rather than by ear. Results are also written as JSON, so reports from different Macs
6
+ * and audio setups (headphones, Bluetooth, external mics) can be compared.
7
+ *
8
+ * 1. Setup everything speak_and_listen needs is installed.
9
+ * 2. Speech → text the voice speaks a known sentence into a file and whisper transcribes it.
10
+ * Checks the voice and the transcriber without involving the room.
11
+ * 3. Speaker → mic the same sentence is spoken live through the speakers (exactly as in a
12
+ * conversation) while the mic records, then transcribed. Checks the real
13
+ * hardware path (skipped with --no-loopback).
14
+ */
15
+ import { mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises";
16
+ import os from "node:os";
17
+ import path from "node:path";
18
+ import { chooseVoice, describeRecorder, findRecorder, recordForSeconds, speak, synthesizeToFile } from "./audio.js";
19
+ import { CONFIG, IS_MAC, PKG } from "./config.js";
20
+ import { kokoroEnabled, kokoroVoiceLabel, stopKokoro } from "./kokoro.js";
21
+ import { ensureModel } from "./model.js";
22
+ import { run } from "./proc.js";
23
+ import { runSetupFlow } from "./setup.js";
24
+ import { describeStt, stopWhisperServer, transcribe } from "./stt.js";
25
+ import { wordErrorRate } from "./wer.js";
26
+ export const DOCTOR_SENTENCE = "The build passed and all tests are green. Should I open the pull request now?";
27
+ /** Word error rate limits: [PASS at or below, WARN at or below]; above that is FAIL. */
28
+ export const LIMITS = {
29
+ /** Clean synthetic speech straight into whisper should be near perfect. */
30
+ file: [0.1, 0.25],
31
+ /** Through the air, room noise and a laptop mic make it harder. */
32
+ loopback: [0.3, 0.5],
33
+ };
34
+ /** Kokoro, model loaded: milliseconds from "speak" to the first sound, [PASS at or below, WARN at or below]. */
35
+ export const FIRST_SOUND_MS = [1500, 3000];
36
+ /** Ends the run early without counting as an unexpected error. */
37
+ class StopChecks extends Error {
38
+ }
39
+ const grade = (wer, [pass, warn]) => (wer <= pass ? "PASS" : wer <= warn ? "WARN" : "FAIL");
40
+ const pct = (x) => `${Math.round(x * 100)}%`;
41
+ /** WAV length in seconds (16 kHz mono 16-bit, 44-byte header). */
42
+ async function wavSeconds(file) {
43
+ return Math.max(0, ((await stat(file)).size - 44) / 32000);
44
+ }
45
+ /** The default input and output device names (macOS), for the report. Best effort. */
46
+ async function audioDevices() {
47
+ if (!IS_MAC)
48
+ return {};
49
+ try {
50
+ const r = await run("/usr/sbin/system_profiler", ["SPAudioDataType", "-json"], { timeoutMs: 10_000 });
51
+ const items = (JSON.parse(r.stdout).SPAudioDataType?.[0]?._items ?? []);
52
+ const find = (key) => items.find((d) => d[key] === "spaudio_yes")?._name ?? "unknown";
53
+ return { input: find("coreaudio_default_audio_input_device"), output: find("coreaudio_default_audio_output_device") };
54
+ }
55
+ catch {
56
+ return {};
57
+ }
58
+ }
59
+ export async function runDoctor(opts = {}) {
60
+ const say = opts.log ?? (() => { });
61
+ const checks = [];
62
+ const add = (c) => {
63
+ checks.push(c);
64
+ say(`${c.status.padEnd(4)} ${c.name}: ${c.detail}`);
65
+ };
66
+ const tmp = await mkdtemp(path.join(os.tmpdir(), "voice-mcp-doctor-"));
67
+ const report = {
68
+ version: PKG.version,
69
+ date: new Date().toISOString(),
70
+ machine: { platform: `${process.platform} ${os.release()}`, arch: process.arch, node: process.version, ...(await audioDevices()) },
71
+ checks,
72
+ ok: false,
73
+ };
74
+ try {
75
+ // 1. Setup
76
+ const setup = await runSetupFlow();
77
+ add({ name: "Setup", status: setup.ready ? "PASS" : "FAIL", detail: setup.ready ? "everything is installed" : "something is missing — run `setup`" });
78
+ if (!setup.ready)
79
+ throw new StopChecks();
80
+ const voice = await chooseVoice();
81
+ const recorder = findRecorder();
82
+ const kokoro = kokoroEnabled();
83
+ Object.assign(report.machine, {
84
+ voice: kokoro ? `${kokoroVoiceLabel()}, built-in fallback: ${voice.label}` : voice.label,
85
+ recorder: recorder ? describeRecorder(recorder) : "none",
86
+ stt: describeStt(),
87
+ model: CONFIG.modelPath ?? CONFIG.modelName,
88
+ });
89
+ const model = await ensureModel({ download: false });
90
+ // 2. Speech → text, straight from a file
91
+ const spoken = path.join(tmp, "spoken.wav");
92
+ let t = Date.now();
93
+ const cold = await synthesizeToFile(DOCTOR_SENTENCE, spoken);
94
+ const synthMs = Date.now() - t;
95
+ if (cold.engine === "kokoro") {
96
+ // Again with the voice loaded: how long a normal turn waits before the first sound.
97
+ const warm = await synthesizeToFile(DOCTOR_SENTENCE, spoken);
98
+ const ms = warm.firstAudioMs ?? 0;
99
+ const [pass, warn] = FIRST_SOUND_MS;
100
+ add({
101
+ name: "Kokoro voice",
102
+ status: ms <= pass ? "PASS" : ms <= warn ? "WARN" : "FAIL",
103
+ detail: `first sound after ${ms} ms (${cold.firstAudioMs ?? 0} ms when the voice had to load first)`,
104
+ metrics: { firstSoundMs: ms, firstSoundColdMs: cold.firstAudioMs ?? 0 },
105
+ });
106
+ }
107
+ t = Date.now();
108
+ const heardColdText = await transcribe(spoken, model);
109
+ const coldMs = Date.now() - t;
110
+ t = Date.now();
111
+ const heard = await transcribe(spoken, model); // again, now that the model is loaded
112
+ const warmMs = Date.now() - t;
113
+ const fileScore = wordErrorRate(DOCTOR_SENTENCE, heard || heardColdText);
114
+ const fileStatus = grade(fileScore.wer, LIMITS.file);
115
+ add({
116
+ name: "Speech → text",
117
+ status: fileStatus,
118
+ detail: `${pct(1 - fileScore.wer)} of words right (heard: "${heard}") — voice ${synthMs} ms, transcribe ${warmMs} ms (first ${coldMs} ms)`,
119
+ metrics: { wer: fileScore.wer, engine: cold.engine, synthMs, transcribeColdMs: coldMs, transcribeWarmMs: warmMs, audioSeconds: await wavSeconds(spoken) },
120
+ });
121
+ // 3. Speaker → mic, through the air
122
+ if (opts.loopback === false || !IS_MAC) {
123
+ add({ name: "Speaker → mic", status: "SKIP", detail: IS_MAC ? "skipped (--no-loopback)" : "macOS only" });
124
+ }
125
+ else {
126
+ const recorded = path.join(tmp, "recorded.wav");
127
+ const seconds = (await wavSeconds(spoken)) + 1.5;
128
+ let player;
129
+ const rec = await recordForSeconds(seconds, recorded, () => {
130
+ // The live voice at full quality, the way speak_and_listen speaks — not the 16 kHz test file.
131
+ player ??= new Promise((r) => setTimeout(r, 300)).then(() => speak(DOCTOR_SENTENCE));
132
+ });
133
+ await player;
134
+ if (rec.digitalSilence) {
135
+ add({
136
+ name: "Speaker → mic",
137
+ status: "FAIL",
138
+ detail: "the mic returned pure silence — allow microphone access for this app (System Settings → Privacy & Security → Microphone), or check the input device",
139
+ metrics: { peakDb: rec.peakDb },
140
+ });
141
+ }
142
+ else if (rec.peakDb < -50) {
143
+ add({
144
+ name: "Speaker → mic",
145
+ status: "WARN",
146
+ detail: `almost nothing reached the mic (peak ${Math.round(rec.peakDb)} dB). Expected with headphones — otherwise turn the volume up and run again`,
147
+ metrics: { peakDb: rec.peakDb },
148
+ });
149
+ }
150
+ else {
151
+ t = Date.now();
152
+ const heardAir = await transcribe(recorded, model);
153
+ const airMs = Date.now() - t;
154
+ const air = wordErrorRate(DOCTOR_SENTENCE, heardAir);
155
+ add({
156
+ name: "Speaker → mic",
157
+ status: grade(air.wer, LIMITS.loopback),
158
+ detail: `${pct(1 - air.wer)} of words right through the air (heard: "${heardAir}"), peak ${Math.round(rec.peakDb)} dB, transcribe ${airMs} ms`,
159
+ metrics: { wer: air.wer, peakDb: rec.peakDb, transcribeMs: airMs },
160
+ });
161
+ }
162
+ }
163
+ }
164
+ catch (err) {
165
+ if (!(err instanceof StopChecks)) {
166
+ add({ name: "Unexpected error", status: "FAIL", detail: err instanceof Error ? err.message : String(err) });
167
+ }
168
+ }
169
+ finally {
170
+ stopWhisperServer();
171
+ stopKokoro();
172
+ await rm(tmp, { recursive: true, force: true });
173
+ }
174
+ report.ok = checks.every((c) => c.status !== "FAIL");
175
+ const dir = opts.reportDir ?? path.join(CONFIG.cacheDir, "doctor");
176
+ try {
177
+ await mkdir(dir, { recursive: true });
178
+ report.reportFile = path.join(dir, `doctor-${report.date.replace(/[:.]/g, "-")}.json`);
179
+ await writeFile(report.reportFile, JSON.stringify(report, null, 2) + "\n");
180
+ }
181
+ catch {
182
+ /* the report file is a convenience */
183
+ }
184
+ return report;
185
+ }
package/dist/index.js CHANGED
@@ -23,16 +23,25 @@
23
23
  * speech-text.ts screen text → ear text; transcript clean-up (pure)
24
24
  * endpointer.ts turn-taking voice-activity detection (pure)
25
25
  * audio.ts TTS, chimes, streaming microphone capture
26
+ * kokoro.ts the optional Kokoro voice: install, warm worker, streaming playback
27
+ * kokoro-worker.ts the process that runs the Kokoro model
28
+ * kokoro-text.ts pronunciation fixes and sentence chunks for Kokoro (pure)
29
+ * pcm.ts PCM conversion and resampling (pure)
26
30
  * model.ts find / symlink / download the whisper model
27
31
  * stt.ts warm whisper-server + whisper-cli fallback
28
32
  * setup.ts requirement checks and consent-based installs
33
+ * lock.ts one voice turn at a time across every session on the Mac
29
34
  * voice.ts the speak → listen → transcribe round trip
35
+ * wer.ts word error rate (pure)
36
+ * doctor.ts objective self-check with PASS / WARN / FAIL
30
37
  * texts.ts everything the model reads (rules, instructions, prompts)
31
38
  * server.ts MCP tools and prompts
32
39
  */
33
40
  import { createInterface } from "node:readline/promises";
34
41
  import { DEFAULT_LISTEN_SECONDS, envNum, log, PKG } from "./config.js";
35
42
  import { startServer } from "./server.js";
43
+ import { runDoctor } from "./doctor.js";
44
+ import { stopKokoro } from "./kokoro.js";
36
45
  import { runSetupFlow } from "./setup.js";
37
46
  import { stopWhisperServer } from "./stt.js";
38
47
  import { speakAndListen } from "./voice.js";
@@ -43,7 +52,10 @@ Usage:
43
52
  npx -y ${PKG.name} Start the MCP server on stdio (what MCP clients run)
44
53
  npx -y ${PKG.name} setup Check what's installed; offers to install what's missing
45
54
  npx -y ${PKG.name} setup --install Install what's missing without asking (brew + model download)
55
+ npx -y ${PKG.name} setup --kokoro Install the optional Kokoro voice (about 1 GB on disk)
46
56
  npx -y ${PKG.name} test ["text"] Speak, listen for one turn and print the transcript
57
+ npx -y ${PKG.name} doctor Self-check: setup, voice → transcript, speakers → mic (PASS/WARN/FAIL)
58
+ npx -y ${PKG.name} doctor --no-loopback …without playing sound through the speakers
47
59
  npx -y ${PKG.name} --version
48
60
 
49
61
  Environment variables (all optional):
@@ -53,8 +65,11 @@ Environment variables (all optional):
53
65
  VOICE_MCP_LANGUAGE spoken language: en, th, de, … or auto
54
66
  VOICE_MCP_WHISPER_PROMPT words to bias recognition toward (names, jargon)
55
67
  VOICE_MCP_VOICE / _RATE macOS say voice and words-per-minute
68
+ VOICE_MCP_TTS auto (Kokoro once installed) | say | kokoro
69
+ VOICE_MCP_KOKORO_VOICE Kokoro voice (default af_heart; e.g. af_bella, am_michael, bf_emma)
70
+ VOICE_MCP_KOKORO_SPEED Kokoro speed, 0.5–2 (default 1)
56
71
  VOICE_MCP_END_SILENCE_MS pause length that ends your turn (default 1200)
57
- VOICE_MCP_START_TIMEOUT_SECONDS wait this long for you to start talking (default 8)
72
+ VOICE_MCP_START_TIMEOUT_SECONDS wait this long for you to start talking (default 15)
58
73
  VOICE_MCP_SPEECH_MARGIN_DB how far above room noise counts as speech (default 12)
59
74
  VOICE_MCP_WHISPER_SERVER 0 to always use whisper-cli (no warm server)
60
75
  VOICE_MCP_SERVER_IDLE_MINUTES stop the warm server after this idle time (default 15)
@@ -64,7 +79,9 @@ Environment variables (all optional):
64
79
  async function runSetupCli(args) {
65
80
  console.error(`${PKG.name} v${PKG.version} on ${process.platform}/${process.arch}, Node ${process.version}\n`);
66
81
  const force = args.includes("--install") || args.includes("-y");
67
- let outcome = await runSetupFlow(force, (m) => console.error(` … ${m}`));
82
+ const kokoro = args.includes("--kokoro");
83
+ const onProgress = (m) => console.error(` … ${m}`);
84
+ let outcome = await runSetupFlow({ install: force, kokoro, onProgress });
68
85
  // Interactive terminal: offer to install right away.
69
86
  if (!force && !outcome.ready && !outcome.installing && process.stdin.isTTY && /ask the user whether/.test(outcome.report)) {
70
87
  console.error(outcome.report.replace(/\nNext:.*$/s, ""));
@@ -73,14 +90,27 @@ async function runSetupCli(args) {
73
90
  rl.close();
74
91
  if (answer !== "" && !answer.startsWith("y"))
75
92
  return 1;
76
- outcome = await runSetupFlow(true, (m) => console.error(` … ${m}`));
93
+ outcome = await runSetupFlow({ install: true, onProgress });
77
94
  }
78
95
  // In a terminal we can simply wait for background installs to finish.
79
96
  while (outcome.installing)
80
- outcome = await runSetupFlow(false, (m) => console.error(` … ${m}`));
97
+ outcome = await runSetupFlow({ onProgress });
81
98
  console.error(outcome.report);
82
99
  return outcome.ready ? 0 : 1;
83
100
  }
101
+ async function runDoctorCli(args) {
102
+ console.error(`${PKG.name} v${PKG.version} doctor — checking the voice pipeline on this machine\n`);
103
+ const loopback = !args.includes("--no-loopback");
104
+ if (loopback)
105
+ console.error("You'll hear a short sentence from the speakers. Keep the room quiet and don't talk.\n");
106
+ const report = await runDoctor({ loopback, log: (line) => console.error(line) });
107
+ console.error(`\n${report.ok ? "All checks passed." : "Some checks failed — see the details above."}`);
108
+ if (report.reportFile)
109
+ console.error(`Report saved to ${report.reportFile}`);
110
+ if (args.includes("--json"))
111
+ process.stdout.write(JSON.stringify(report, null, 2) + "\n");
112
+ return report.ok ? 0 : 1;
113
+ }
84
114
  async function runTestCli(text) {
85
115
  const prompt = text || "Voice bridge test. Say something after the chime, and I'll print what I heard.";
86
116
  try {
@@ -99,6 +129,7 @@ async function runTestCli(text) {
99
129
  }
100
130
  finally {
101
131
  stopWhisperServer();
132
+ stopKokoro();
102
133
  }
103
134
  }
104
135
  async function main() {
@@ -108,8 +139,9 @@ async function main() {
108
139
  case "serve":
109
140
  return startServer();
110
141
  case "setup":
111
- case "doctor":
112
142
  process.exit(await runSetupCli(rest));
143
+ case "doctor":
144
+ process.exit(await runDoctorCli(rest));
113
145
  case "test":
114
146
  process.exit(await runTestCli(rest.join(" ").trim()));
115
147
  case "-v":