mac-voice-mcp 0.3.1 → 0.4.1

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/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";
6
- import { Endpointer, FRAME_BYTES, wavHeader } from "./endpointer.js";
5
+ import { CONFIG, debug, IS_MAC, IS_WIN, log } from "./config.js";
6
+ import { Endpointer, FRAME_BYTES, FRAME_MS, 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,13 +203,76 @@ 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
+ }
263
+ /** Audio the mic must have delivered before we tell the user it's open: the recorder is running, and the room's level is known. */
264
+ export const MIC_WARMUP_MS = 250;
155
265
  /**
156
266
  * Listen for one conversational turn and write it to `outFile` (16 kHz mono WAV).
157
267
  * Streams raw PCM from the recorder, runs the endpointer on it live, and stops the
158
268
  * recorder the moment the user finishes. Leading and trailing silence are trimmed
159
269
  * (keeping a little padding) so whisper gets just the utterance.
270
+ *
271
+ * The recorder starts first and `onListening` (the chime) comes only once audio is flowing, so
272
+ * someone who starts talking right at the chime is never cut off: opening a mic takes a moment,
273
+ * longer with Bluetooth.
160
274
  */
161
- export async function listenForTurn(maxSeconds, outFile, signal) {
275
+ export async function listenForTurn(maxSeconds, outFile, signal, opts = {}) {
162
276
  const recorder = findRecorder();
163
277
  if (!recorder)
164
278
  throw new SetupError(RECORDER_MISSING);
@@ -173,6 +287,19 @@ export async function listenForTurn(maxSeconds, outFile, signal) {
173
287
  let pending = Buffer.alloc(0);
174
288
  let reason = null;
175
289
  let stderr = "";
290
+ const warmupFrames = Math.ceil(MIC_WARMUP_MS / FRAME_MS);
291
+ let announced = false;
292
+ const announce = () => {
293
+ announced = true;
294
+ if (!opts.onListening)
295
+ return;
296
+ endpointer.setIgnoring(true);
297
+ Promise.resolve()
298
+ .then(opts.onListening)
299
+ .catch(() => { })
300
+ // A short tail: the chime's echo fades out of the mic a moment after it ends.
301
+ .finally(() => setTimeout(() => endpointer.setIgnoring(false), 80));
302
+ };
176
303
  const args = recorderArgs(recorder);
177
304
  debug("exec:", recorder.bin, args.join(" "));
178
305
  const child = spawn(recorder.bin, args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
@@ -200,6 +327,8 @@ export async function listenForTurn(maxSeconds, outFile, signal) {
200
327
  off += FRAME_BYTES;
201
328
  frames.push(frame);
202
329
  reason = endpointer.push(frame);
330
+ if (!announced && frames.length >= warmupFrames)
331
+ announce();
203
332
  }
204
333
  pending = pending.subarray(off);
205
334
  if (reason)
package/dist/config.js CHANGED
@@ -27,6 +27,23 @@ export function envBool(name, fallback) {
27
27
  return !["0", "false", "no", "off"].includes(raw);
28
28
  }
29
29
  const modelName = (process.env.VOICE_MCP_WHISPER_MODEL ?? "base.en").trim();
30
+ const language = process.env.VOICE_MCP_LANGUAGE?.trim() || (modelName.endsWith(".en") ? "en" : "auto");
31
+ /**
32
+ * The default hint for English: telling whisper what the conversation is about makes it hear
33
+ * developer words better ("rebase", "backend", "passed"). Measured: 60 clips, 3 voices, word errors
34
+ * 3.4% → 2.3% on everyday sentences and 3.8% → 3.1% on jargon, with no words invented in silence.
35
+ * A plain word list did worse than no hint, so it's a sentence.
36
+ */
37
+ export const DEVELOPER_PROMPT = "A software developer talks to a coding assistant about the repo: the build, tests, commits, branches, pull requests, npm, JSON, TypeScript, the API and CI.";
38
+ /** VOICE_MCP_WHISPER_PROMPT: unset = the developer hint (English only), "none" = no hint, anything else = that text. */
39
+ function whisperPrompt() {
40
+ const raw = process.env.VOICE_MCP_WHISPER_PROMPT?.trim();
41
+ if (raw && ["none", "off", "0", "false"].includes(raw.toLowerCase()))
42
+ return undefined;
43
+ if (raw)
44
+ return raw;
45
+ return language === "en" ? DEVELOPER_PROMPT : undefined;
46
+ }
30
47
  /** Everything this server ever downloads or links lives here, shared by every version and every client. */
31
48
  const CACHE_ROOT = process.env.VOICE_MCP_CACHE_DIR?.trim() ||
32
49
  path.join(process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache"), "mac-voice-mcp");
@@ -36,6 +53,21 @@ export const CONFIG = {
36
53
  /** How long a voice turn waits for another session to finish with the mic. */
37
54
  lockWaitSeconds: Math.max(1, envNum("VOICE_MCP_LOCK_WAIT_SECONDS", 120)),
38
55
  // --- Speaking
56
+ /**
57
+ * Which voice engine: auto (default: the Kokoro voice once it's installed, else the built-in one) |
58
+ * say (always the built-in voice: macOS `say`, espeak-ng elsewhere) | kokoro (Kokoro; voice_setup offers to install it).
59
+ */
60
+ tts: (["say", "kokoro"].includes(process.env.VOICE_MCP_TTS?.trim().toLowerCase() ?? "")
61
+ ? process.env.VOICE_MCP_TTS.trim().toLowerCase()
62
+ : "auto"),
63
+ /** Kokoro voice, American (a…) or British (b…) English: af_heart (default), af_bella, am_michael, bf_emma, … */
64
+ kokoroVoice: process.env.VOICE_MCP_KOKORO_VOICE?.trim() || "af_heart",
65
+ /** Kokoro speaking speed (1 = normal, 0.5–2). */
66
+ kokoroSpeed: Math.min(2, Math.max(0.5, envNum("VOICE_MCP_KOKORO_SPEED", 1))),
67
+ /** Kokoro model precision: fp32 (default: about twice as fast on a CPU, ~330 MB) or q8 (~90 MB, slower). */
68
+ kokoroDtype: (process.env.VOICE_MCP_KOKORO_DTYPE?.trim().toLowerCase() === "q8" ? "q8" : "fp32"),
69
+ /** Where the optional Kokoro voice (kokoro-js and its model) is installed. */
70
+ kokoroDir: process.env.VOICE_MCP_KOKORO_DIR?.trim() || path.join(CACHE_ROOT, "kokoro"),
39
71
  /**
40
72
  * macOS voice name, e.g. "Ava (Premium)" (`say -v '?'` lists them). Unset: the most natural
41
73
  * installed voice for the language (Premium, then Enhanced), else the system voice. "default": always the system voice.
@@ -81,9 +113,9 @@ export const CONFIG = {
81
113
  /** Stop the warm whisper-server after this many idle minutes to free memory. */
82
114
  serverIdleMinutes: Math.max(1, envNum("VOICE_MCP_SERVER_IDLE_MINUTES", 15)),
83
115
  /** Spoken language code ("en", "th", "de", ...) or "auto". Defaults to "en" for *.en models, else "auto". */
84
- language: process.env.VOICE_MCP_LANGUAGE?.trim() || (modelName.endsWith(".en") ? "en" : "auto"),
85
- /** Optional initial prompt to bias vocabulary (names, jargon). */
86
- prompt: process.env.VOICE_MCP_WHISPER_PROMPT?.trim() || undefined,
116
+ language,
117
+ /** Initial prompt that biases vocabulary: the developer hint by default (English), or your own (names, jargon). */
118
+ prompt: whisperPrompt(),
87
119
  threads: Math.max(1, Math.floor(envNum("VOICE_MCP_THREADS", Math.min(8, os.cpus().length || 4)))),
88
120
  debug: envBool("VOICE_MCP_DEBUG", false),
89
121
  };
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
+ }
@@ -6,6 +6,7 @@
6
6
  * - once speaking, ends the turn after `endSilenceMs` of quiet
7
7
  * - ignores blips shorter than 300 ms (a cough, a click) and keeps listening
8
8
  * - hard stop at `maxMs`
9
+ * - while `ignoring` (our own "mic open" chime is playing), frames are neither speech nor background
9
10
  */
10
11
  export const SAMPLE_RATE = 16000;
11
12
  export const FRAME_MS = 30;
@@ -46,6 +47,8 @@ export class Endpointer {
46
47
  lastVoicedFrame = -1;
47
48
  /** False while every sample so far has been exactly zero (a blocked mic). */
48
49
  anyNonZero = false;
50
+ /** True while our own chime plays: its sound reaches the mic, but it isn't the user. */
51
+ ignoring = false;
49
52
  constructor(opts) {
50
53
  this.opts = opts;
51
54
  this.maxFrames = Math.ceil(opts.maxMs / FRAME_MS);
@@ -53,6 +56,12 @@ export class Endpointer {
53
56
  this.endSilenceFrames = Math.ceil(opts.endSilenceMs / FRAME_MS);
54
57
  this.historySize = Math.max(FLOOR_WINDOW_FRAMES, this.endSilenceFrames, Math.ceil(1000 / FRAME_MS));
55
58
  }
59
+ /** Stop (true) or resume (false) judging frames, e.g. while the "mic open" chime plays. Time still counts. */
60
+ setIgnoring(on) {
61
+ this.ignoring = on;
62
+ if (on && !this.started)
63
+ this.loudRun = 0;
64
+ }
56
65
  /** Feed one 30 ms frame of 16-bit little-endian PCM; returns a reason once the turn is over. */
57
66
  push(frame) {
58
67
  const view = new DataView(frame.buffer, frame.byteOffset, frame.byteLength);
@@ -67,6 +76,8 @@ export class Endpointer {
67
76
  const rms = Math.sqrt(sumSq / Math.max(1, n)) / 32768;
68
77
  const db = Math.max(-100, 20 * Math.log10(rms + 1e-9));
69
78
  const idx = this.frames++;
79
+ if (this.ignoring)
80
+ return this.timeLimit();
70
81
  if (idx >= SETTLE_FRAMES) {
71
82
  this.history.push(db);
72
83
  if (this.history.length > this.historySize)
@@ -129,6 +140,9 @@ export class Endpointer {
129
140
  }
130
141
  }
131
142
  }
143
+ return this.timeLimit();
144
+ }
145
+ timeLimit() {
132
146
  if (this.frames >= this.maxFrames)
133
147
  return this.everStarted ? "max-duration" : "no-speech";
134
148
  if (!this.everStarted && this.frames >= this.startTimeoutFrames)
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):
@@ -51,10 +63,13 @@ Environment variables (all optional):
51
63
  VOICE_MCP_WHISPER_MODEL_PATH use this ggml model file
52
64
  VOICE_MCP_MODEL_SEARCH_PATHS extra folders to look in for an existing model (":"-separated)
53
65
  VOICE_MCP_LANGUAGE spoken language: en, th, de, … or auto
54
- VOICE_MCP_WHISPER_PROMPT words to bias recognition toward (names, jargon)
66
+ VOICE_MCP_WHISPER_PROMPT a sentence about the topic, to hear its words better (default: a developer hint; none = off)
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":