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/CHANGELOG.md +38 -1
- package/README.md +72 -264
- package/dist/audio.js +133 -4
- package/dist/config.js +35 -3
- package/dist/doctor.js +185 -0
- package/dist/endpointer.js +14 -0
- package/dist/index.js +38 -6
- 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 +58 -13
- package/dist/texts.js +2 -0
- package/dist/voice.js +23 -9
- package/dist/wer.js +41 -0
- package/package.json +4 -2
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
|
|
85
|
-
/**
|
|
86
|
-
prompt:
|
|
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
|
+
}
|
package/dist/endpointer.js
CHANGED
|
@@ -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
|
|
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
|
|
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":
|