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/CHANGELOG.md +31 -1
- package/README.md +35 -8
- package/dist/audio.js +110 -2
- package/dist/config.js +17 -2
- 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 +82 -18
- package/dist/texts.js +2 -0
- package/dist/voice.js +30 -9
- package/dist/wer.js +41 -0
- package/package.json +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,33 @@ 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
|
+
|
|
26
|
+
## [0.3.1] - 2026-09-29
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
- **Recording broke on Macs without SoX when Bluetooth headphones were connected** ([#6](https://github.com/jeet0007/mac-voice-mcp/issues/6)). Every turn failed with "pure digital silence".
|
|
30
|
+
- The ffmpeg fallback now records from the input chosen in System Settings (`:default`) instead of device number 0. `VOICE_MCP_FFMPEG_DEVICE` still overrides it.
|
|
31
|
+
- `voice_setup` treats ffmpeg as a fallback, recommends SoX, and installs it with your OK.
|
|
32
|
+
- Silence recorded through ffmpeg now points at the input device and SoX, not only at mic permissions.
|
|
33
|
+
- **`voice_setup` notices tools installed since the last check**, such as `brew install sox` run in a terminal, without restarting the app.
|
|
34
|
+
|
|
8
35
|
## [0.3.0] - 2026-09-28
|
|
9
36
|
|
|
10
37
|
### Added
|
|
@@ -69,7 +96,10 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
|
|
69
96
|
- `voice_setup`: checks first, installs only what's missing, and only after you agree.
|
|
70
97
|
- The `setup` and `voice_mode` prompts.
|
|
71
98
|
|
|
72
|
-
[Unreleased]: https://github.com/jeet0007/mac-voice-mcp/compare/v0.
|
|
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
|
|
73
103
|
[0.2.0]: https://github.com/jeet0007/mac-voice-mcp/compare/v0.1.1...v0.2.0
|
|
74
104
|
[0.1.1]: https://github.com/jeet0007/mac-voice-mcp/compare/119a589...v0.1.1
|
|
75
105
|
[0.1.0]: https://github.com/jeet0007/mac-voice-mcp/tree/119a589
|
package/README.md
CHANGED
|
@@ -123,7 +123,7 @@ Setup checks what's already there before it changes anything:
|
|
|
123
123
|
| Needed | Provided by | If it's missing |
|
|
124
124
|
|---|---|---|
|
|
125
125
|
| Voice | macOS `say`, using the most natural voice installed | Nothing to do. For a far better voice, add a free Premium one (see below). |
|
|
126
|
-
| Microphone capture | SoX (`rec`) | `brew install sox` |
|
|
126
|
+
| Microphone capture | SoX (`rec`). ffmpeg works as a fallback, but setup recommends SoX | `brew install sox` |
|
|
127
127
|
| Speech-to-text | whisper.cpp (`whisper-cli` and `whisper-server`, Metal-accelerated) | `brew install whisper-cpp` |
|
|
128
128
|
| Speech model | `base.en`, ~140 MB | Downloaded once to `~/.cache/mac-voice-mcp/models/` |
|
|
129
129
|
|
|
@@ -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. |
|
|
@@ -235,7 +248,8 @@ Everything is optional. Set these in your client config's `"env": { … }` block
|
|
|
235
248
|
| `VOICE_MCP_START_TIMEOUT_SECONDS` | `15` | How long to wait for you to start talking. |
|
|
236
249
|
| `VOICE_MCP_SPEECH_MARGIN_DB` | `12` | How much louder than room noise counts as speech. Raise it in noisy rooms. |
|
|
237
250
|
| `VOICE_MCP_MIN_SPEECH_DB` | `-48` | The quietest level that ever counts as speech (dBFS). |
|
|
238
|
-
| `VOICE_MCP_RECORDER` | `auto` | `sox` or `ffmpeg` (`ffmpeg` is macOS only). |
|
|
251
|
+
| `VOICE_MCP_RECORDER` | `auto` | `sox` or `ffmpeg` (`ffmpeg` is macOS only). `auto` uses SoX, falling back to ffmpeg. |
|
|
252
|
+
| `VOICE_MCP_FFMPEG_DEVICE` | `:default` | Which input ffmpeg records from. `:default` follows System Settings; `:1` picks device 1 (list them with `ffmpeg -f avfoundation -list_devices true -i ""`). |
|
|
239
253
|
|
|
240
254
|
**Speech-to-text**
|
|
241
255
|
|
|
@@ -271,15 +285,17 @@ Everything is optional. Set these in your client config's `"env": { … }` block
|
|
|
271
285
|
|
|
272
286
|
| Symptom | Fix |
|
|
273
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. |
|
|
274
289
|
| "voice-mcp is not set up yet" | Ask Claude to *set up voice*, or run `npx -y mac-voice-mcp@latest setup`. |
|
|
275
|
-
| "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. |
|
|
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`. |
|
|
276
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. |
|
|
277
292
|
| It cuts me off while I'm thinking | Set `VOICE_MCP_END_SILENCE_MS=1800` (or up to `2500`). |
|
|
278
293
|
| It never stops listening | The room is too noisy for the defaults. Set `VOICE_MCP_SPEECH_MARGIN_DB=18`, or use a headset. |
|
|
279
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. |
|
|
280
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. |
|
|
281
296
|
| It garbles names or jargon | Set `VOICE_MCP_WHISPER_PROMPT="Priya, Postgres, Kubernetes"`, or switch to `small.en`. |
|
|
282
|
-
| The voice sounds robotic | Download a Premium voice (see [Get a better voice](#then-set-up-and-allow-the-mic)).
|
|
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`. |
|
|
283
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). |
|
|
284
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. |
|
|
285
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). |
|
|
@@ -294,9 +310,10 @@ Everything is optional. Set these in your client config's `"env": { … }` block
|
|
|
294
310
|
|
|
295
311
|
## Privacy and safety
|
|
296
312
|
|
|
297
|
-
- **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.
|
|
298
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.
|
|
299
|
-
- **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.
|
|
300
317
|
|
|
301
318
|
## Security
|
|
302
319
|
|
|
@@ -315,6 +332,8 @@ npm test # build + unit tests, end-to-end tests over MCP with stub
|
|
|
315
332
|
npm run audit # known-vulnerability and signature checks on dependencies
|
|
316
333
|
npm run setup # check what's installed; offers to install what's missing
|
|
317
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
|
|
318
337
|
npm run inspect # MCP Inspector
|
|
319
338
|
```
|
|
320
339
|
|
|
@@ -324,6 +343,8 @@ npm run inspect # MCP Inspector
|
|
|
324
343
|
| `speech-text.ts` | Rewriting screen text for speech, cleaning up transcripts (pure, unit-tested) |
|
|
325
344
|
| `endpointer.ts` | Turn-taking voice-activity detection (pure, unit-tested) |
|
|
326
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) |
|
|
327
348
|
| `model.ts` | Finding, symlinking or downloading the model |
|
|
328
349
|
| `stt.ts` | The warm `whisper-server` with orphan guard, and the `whisper-cli` fallback |
|
|
329
350
|
| `setup.ts` | Requirement checks and consent-based background installs |
|
|
@@ -333,7 +354,13 @@ npm run inspect # MCP Inspector
|
|
|
333
354
|
|
|
334
355
|
The package installs two commands: `mac-voice-mcp` (the one `npx -y mac-voice-mcp` runs), and `voice-mcp`.
|
|
335
356
|
|
|
336
|
-
**Testing
|
|
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`.
|
|
337
364
|
|
|
338
365
|
### Releasing
|
|
339
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.
|
|
@@ -58,8 +73,8 @@ export const CONFIG = {
|
|
|
58
73
|
minSpeechDb: envNum("VOICE_MCP_MIN_SPEECH_DB", -48),
|
|
59
74
|
/** Recorder: auto (SoX, else ffmpeg) | sox | ffmpeg. */
|
|
60
75
|
recorder: (process.env.VOICE_MCP_RECORDER?.trim().toLowerCase() || "auto"),
|
|
61
|
-
/** ffmpeg avfoundation audio input (macOS fallback recorder). */
|
|
62
|
-
ffmpegDevice: process.env.VOICE_MCP_FFMPEG_DEVICE?.trim() || ":
|
|
76
|
+
/** ffmpeg avfoundation audio input (macOS fallback recorder). ":default" follows the input chosen in System Settings. */
|
|
77
|
+
ffmpegDevice: process.env.VOICE_MCP_FFMPEG_DEVICE?.trim() || ":default",
|
|
63
78
|
// --- Speech-to-text
|
|
64
79
|
/** whisper.cpp model name (tiny.en, base.en, small.en, large-v3-turbo-q5_0, ...). */
|
|
65
80
|
modelName,
|
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
|
|
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
|
-
|
|
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,
|
|
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(
|
|
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":
|