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/dist/pcm.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** Small PCM helpers for the Kokoro voice (pure, unit-tested). */
|
|
2
|
+
/** Float samples (-1…1) → 16-bit little-endian PCM, clipped. */
|
|
3
|
+
export function floatToPcm16(samples) {
|
|
4
|
+
const buf = Buffer.alloc(samples.length * 2);
|
|
5
|
+
for (let i = 0; i < samples.length; i++) {
|
|
6
|
+
const s = Math.max(-1, Math.min(1, samples[i]));
|
|
7
|
+
buf.writeInt16LE(Math.round(s < 0 ? s * 0x8000 : s * 0x7fff), i * 2);
|
|
8
|
+
}
|
|
9
|
+
return buf;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Resample 16-bit mono PCM by linear interpolation (24 kHz → 16 kHz for whisper). Good enough
|
|
13
|
+
* for speech recognition, which is all it's used for; playback always uses the original rate.
|
|
14
|
+
*/
|
|
15
|
+
export function resamplePcm16(pcm, fromRate, toRate) {
|
|
16
|
+
const inCount = Math.floor(pcm.length / 2);
|
|
17
|
+
if (fromRate === toRate || inCount === 0)
|
|
18
|
+
return Buffer.from(pcm.subarray(0, inCount * 2));
|
|
19
|
+
const outCount = Math.floor((inCount * toRate) / fromRate);
|
|
20
|
+
const out = Buffer.alloc(outCount * 2);
|
|
21
|
+
const step = fromRate / toRate;
|
|
22
|
+
for (let i = 0; i < outCount; i++) {
|
|
23
|
+
const x = i * step;
|
|
24
|
+
const i0 = Math.floor(x);
|
|
25
|
+
const i1 = Math.min(i0 + 1, inCount - 1);
|
|
26
|
+
const frac = x - i0;
|
|
27
|
+
const v = pcm.readInt16LE(i0 * 2) * (1 - frac) + pcm.readInt16LE(i1 * 2) * frac;
|
|
28
|
+
out.writeInt16LE(Math.round(v), i * 2);
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
/** A 44-byte WAV header for 16-bit mono PCM. */
|
|
33
|
+
export function wavHeaderFor(dataBytes, sampleRate) {
|
|
34
|
+
const h = Buffer.alloc(44);
|
|
35
|
+
h.write("RIFF", 0);
|
|
36
|
+
h.writeUInt32LE(36 + dataBytes, 4);
|
|
37
|
+
h.write("WAVE", 8);
|
|
38
|
+
h.write("fmt ", 12);
|
|
39
|
+
h.writeUInt32LE(16, 16);
|
|
40
|
+
h.writeUInt16LE(1, 20); // PCM
|
|
41
|
+
h.writeUInt16LE(1, 22); // mono
|
|
42
|
+
h.writeUInt32LE(sampleRate, 24);
|
|
43
|
+
h.writeUInt32LE(sampleRate * 2, 28);
|
|
44
|
+
h.writeUInt16LE(2, 32);
|
|
45
|
+
h.writeUInt16LE(16, 34);
|
|
46
|
+
h.write("data", 36);
|
|
47
|
+
h.writeUInt32LE(dataBytes, 40);
|
|
48
|
+
return h;
|
|
49
|
+
}
|
package/dist/proc.js
CHANGED
|
@@ -64,6 +64,7 @@ export function run(cmd, args, opts = {}) {
|
|
|
64
64
|
const child = spawn(cmd, args, {
|
|
65
65
|
stdio: [opts.input !== undefined ? "pipe" : "ignore", "pipe", "pipe"],
|
|
66
66
|
windowsHide: true,
|
|
67
|
+
cwd: opts.cwd,
|
|
67
68
|
env: opts.env ? { ...process.env, ...opts.env } : process.env,
|
|
68
69
|
});
|
|
69
70
|
activeChildren.add(child);
|
package/dist/server.js
CHANGED
|
@@ -3,6 +3,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { CONFIG, DEFAULT_LISTEN_SECONDS, log, MAX_LISTEN_SECONDS, PKG } from "./config.js";
|
|
6
|
+
import { stopKokoro } from "./kokoro.js";
|
|
6
7
|
import { locateModel } from "./model.js";
|
|
7
8
|
import { activeChildren, SetupError } from "./proc.js";
|
|
8
9
|
import { runSetupFlow } from "./setup.js";
|
|
@@ -74,11 +75,16 @@ export function createServer() {
|
|
|
74
75
|
.boolean()
|
|
75
76
|
.optional()
|
|
76
77
|
.describe("false (default): only check. true: brew install missing packages and download the model. Ask the user first."),
|
|
78
|
+
kokoro: z
|
|
79
|
+
.boolean()
|
|
80
|
+
.optional()
|
|
81
|
+
.describe("true: install the optional Kokoro voice — a natural-sounding neural voice that runs on this Mac " +
|
|
82
|
+
"(kokoro-js via npm and a one-time model download, about 1 GB on disk). Ask the user first."),
|
|
77
83
|
},
|
|
78
84
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
79
|
-
}, async ({ install }, extra) => {
|
|
85
|
+
}, async ({ install, kokoro }, extra) => {
|
|
80
86
|
try {
|
|
81
|
-
const outcome = await exclusive(() => runSetupFlow(install === true, progressReporter(extra)));
|
|
87
|
+
const outcome = await exclusive(() => runSetupFlow({ install: install === true, kokoro: kokoro === true, onProgress: progressReporter(extra) }));
|
|
82
88
|
return { content: [{ type: "text", text: outcome.report }] };
|
|
83
89
|
}
|
|
84
90
|
catch (err) {
|
|
@@ -110,6 +116,7 @@ export async function startServer() {
|
|
|
110
116
|
return;
|
|
111
117
|
shuttingDown = true;
|
|
112
118
|
stopWhisperServer();
|
|
119
|
+
stopKokoro();
|
|
113
120
|
for (const child of activeChildren)
|
|
114
121
|
child.kill("SIGTERM");
|
|
115
122
|
try {
|
package/dist/setup.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { statSync } from "node:fs";
|
|
10
10
|
import { chooseVoice, describeRecorder, findRecorder, findTts, resetVoiceChoice, VOICE_UPGRADE_HINT } from "./audio.js";
|
|
11
11
|
import { CONFIG, IS_MAC, IS_WIN, log } from "./config.js";
|
|
12
|
+
import { installKokoro, isKokoroInstalled, isKokoroModelPresent, KOKORO_SIZES_MB, kokoroVoiceLabel } from "./kokoro.js";
|
|
12
13
|
import { ensureModel, isModelDownloading, locateModel, modelSource, MODEL_SIZES_MB } from "./model.js";
|
|
13
14
|
import { isExecutable, resetWhichCache, run, sleep, tail, which } from "./proc.js";
|
|
14
15
|
import { describeStt, findWhisperCli, findWhisperServer } from "./stt.js";
|
|
@@ -21,6 +22,9 @@ export function findBrew() {
|
|
|
21
22
|
}
|
|
22
23
|
let brewJob = null;
|
|
23
24
|
let modelJob = null;
|
|
25
|
+
let kokoroJob = null;
|
|
26
|
+
/** The Kokoro install's latest progress line. */
|
|
27
|
+
let kokoroProgress = "";
|
|
24
28
|
/** Results of finished jobs not yet shown to the user. */
|
|
25
29
|
const finishedMessages = [];
|
|
26
30
|
function startJob(label, work) {
|
|
@@ -58,6 +62,10 @@ function startModelDownload() {
|
|
|
58
62
|
return `Downloaded the ${CONFIG.modelName} speech model.`;
|
|
59
63
|
});
|
|
60
64
|
}
|
|
65
|
+
function startKokoroInstall() {
|
|
66
|
+
kokoroProgress = "starting";
|
|
67
|
+
return startJob("Installing the Kokoro voice", () => installKokoro((m) => (kokoroProgress = m)));
|
|
68
|
+
}
|
|
61
69
|
const running = (j) => !!j && !j.done;
|
|
62
70
|
const elapsed = (j) => `${Math.round((Date.now() - j.startedAt) / 1000)}s`;
|
|
63
71
|
// --- Checks -------------------------------------------------------------------------------
|
|
@@ -68,17 +76,34 @@ async function checkRequirements() {
|
|
|
68
76
|
checks.push(tts
|
|
69
77
|
? { label: "Text-to-speech", status: "ok", detail: IS_MAC ? "macOS `say` (built in)" : tts }
|
|
70
78
|
: { label: "Text-to-speech", status: "missing", detail: "no engine found — install espeak-ng (`sudo apt install espeak-ng`)" });
|
|
79
|
+
const kokoro = kokoroCheck();
|
|
71
80
|
if (tts && IS_MAC) {
|
|
72
81
|
resetVoiceChoice(); // pick up a voice downloaded since the last check
|
|
73
82
|
const choice = await chooseVoice();
|
|
74
|
-
checks.push(
|
|
75
|
-
? { label: "Voice", status: "
|
|
76
|
-
:
|
|
83
|
+
checks.push(kokoro?.status === "ok"
|
|
84
|
+
? { label: "Voice", status: "ok", detail: `${choice.label} (used if the Kokoro voice ever fails)` }
|
|
85
|
+
: choice.canUpgrade
|
|
86
|
+
? { label: "Voice", status: "optional", detail: `${choice.label} — ${VOICE_UPGRADE_HINT}` }
|
|
87
|
+
: { label: "Voice", status: "ok", detail: choice.label });
|
|
77
88
|
}
|
|
89
|
+
if (kokoro)
|
|
90
|
+
checks.push(kokoro);
|
|
78
91
|
const rec = findRecorder();
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
92
|
+
if (!rec) {
|
|
93
|
+
checks.push({ label: "Recorder", status: brewing ? "installing" : "missing", detail: "SoX is not installed", brew: IS_WIN ? undefined : "sox" });
|
|
94
|
+
}
|
|
95
|
+
else if (rec.kind === "ffmpeg" && CONFIG.recorder === "auto") {
|
|
96
|
+
// Works, but it's the fragile path: recommend SoX, which is what the turn-taking is tuned on.
|
|
97
|
+
checks.push({
|
|
98
|
+
label: "Recorder",
|
|
99
|
+
status: brewing ? "installing" : "optional",
|
|
100
|
+
detail: `${describeRecorder(rec)}, as a fallback. SoX is recommended: it's more reliable with Bluetooth headsets and other audio devices`,
|
|
101
|
+
brew: "sox",
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
checks.push({ label: "Recorder", status: "ok", detail: describeRecorder(rec) });
|
|
106
|
+
}
|
|
82
107
|
const cli = findWhisperCli();
|
|
83
108
|
const server = findWhisperServer();
|
|
84
109
|
if (cli || server) {
|
|
@@ -125,52 +150,91 @@ async function checkRequirements() {
|
|
|
125
150
|
}
|
|
126
151
|
return checks;
|
|
127
152
|
}
|
|
153
|
+
/** The optional Kokoro voice: shown once it's installed or asked for (VOICE_MCP_TTS=kokoro), or while installing. */
|
|
154
|
+
function kokoroCheck() {
|
|
155
|
+
const label = "Kokoro voice";
|
|
156
|
+
if (running(kokoroJob))
|
|
157
|
+
return { label, status: "installing", detail: `${kokoroProgress} (${elapsed(kokoroJob)} so far)` };
|
|
158
|
+
if (CONFIG.tts === "say")
|
|
159
|
+
return null;
|
|
160
|
+
const installed = isKokoroInstalled();
|
|
161
|
+
const size = `~${KOKORO_SIZES_MB[CONFIG.kokoroDtype]} MB`;
|
|
162
|
+
if (installed && isKokoroModelPresent()) {
|
|
163
|
+
return { label, status: "ok", detail: `${kokoroVoiceLabel()} — the natural on-device voice, used for speaking (VOICE_MCP_TTS=say turns it off)` };
|
|
164
|
+
}
|
|
165
|
+
if (installed) {
|
|
166
|
+
return { label, status: "optional", detail: `installed, but its model isn't downloaded yet (${size}) — until then the built-in voice speaks`, kokoro: true };
|
|
167
|
+
}
|
|
168
|
+
if (CONFIG.tts === "kokoro") {
|
|
169
|
+
return {
|
|
170
|
+
label,
|
|
171
|
+
status: "optional",
|
|
172
|
+
detail: `VOICE_MCP_TTS=kokoro, but it isn't installed — a one-time install (kokoro-js with npm, and a ${size} model; about 1 GB on disk) into ${CONFIG.kokoroDir}. Until then the built-in voice speaks`,
|
|
173
|
+
kokoro: true,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
128
178
|
// --- The flow ------------------------------------------------------------------------------
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
*/
|
|
133
|
-
export async function runSetupFlow(install, onProgress) {
|
|
179
|
+
export async function runSetupFlow(opts = {}) {
|
|
180
|
+
const { install = false, onProgress } = opts;
|
|
181
|
+
resetWhichCache(); // see anything installed since the last check (e.g. `brew install sox` in a terminal)
|
|
134
182
|
let checks = await checkRequirements();
|
|
135
183
|
if (install) {
|
|
136
|
-
|
|
184
|
+
// Missing pieces, plus recommended ones (SoX when only ffmpeg is there): the user agreed to install.
|
|
185
|
+
const formulae = [...new Set(checks.filter((c) => (c.status === "missing" || c.status === "optional") && c.brew).map((c) => c.brew))];
|
|
137
186
|
const brew = findBrew();
|
|
138
187
|
if (formulae.length && brew && !running(brewJob))
|
|
139
188
|
brewJob = startBrewInstall(brew, formulae);
|
|
140
189
|
if (checks.some((c) => c.model && c.status === "missing") && !running(modelJob))
|
|
141
190
|
modelJob = startModelDownload();
|
|
142
191
|
}
|
|
192
|
+
if (opts.kokoro && !running(kokoroJob) && !(isKokoroInstalled() && isKokoroModelPresent()))
|
|
193
|
+
kokoroJob = startKokoroInstall();
|
|
143
194
|
// Wait (bounded) for anything in flight, with a heartbeat.
|
|
144
|
-
const jobs = [brewJob, modelJob].filter(running);
|
|
195
|
+
const jobs = [brewJob, modelJob, kokoroJob].filter(running);
|
|
145
196
|
if (jobs.length) {
|
|
146
197
|
const deadline = Date.now() + INSTALL_WAIT_MS;
|
|
147
198
|
while (jobs.some((j) => !j.done) && Date.now() < deadline) {
|
|
148
|
-
onProgress?.(jobs
|
|
199
|
+
onProgress?.(jobs
|
|
200
|
+
.filter((j) => !j.done)
|
|
201
|
+
.map((j) => `${j.label} (${j === kokoroJob ? `${kokoroProgress}, ` : ""}${elapsed(j)})`)
|
|
202
|
+
.join("; "));
|
|
149
203
|
await Promise.race([Promise.all(jobs.map((j) => j.promise)), sleep(5000)]);
|
|
150
204
|
}
|
|
151
205
|
checks = await checkRequirements();
|
|
152
206
|
}
|
|
153
|
-
const installing = running(brewJob) || running(modelJob);
|
|
207
|
+
const installing = running(brewJob) || running(modelJob) || running(kokoroJob);
|
|
154
208
|
const ready = checks.every((c) => c.status === "ok" || c.status === "optional");
|
|
155
209
|
const icon = { ok: "✔", missing: "✘", optional: "•", installing: "…" };
|
|
156
210
|
const done = finishedMessages.splice(0);
|
|
157
211
|
const lines = [
|
|
158
212
|
`voice-mcp setup — ${ready ? "READY" : installing ? "INSTALLING" : "NOT READY"}`,
|
|
159
213
|
"",
|
|
160
|
-
...checks.map((c) => `${icon[c.status]} ${c.label}: ${c.detail}${c.brew && c.status
|
|
214
|
+
...checks.map((c) => `${icon[c.status]} ${c.label}: ${c.detail}${c.brew && c.status !== "ok" && c.status !== "installing" ? ` → brew install ${c.brew}` : ""}`),
|
|
161
215
|
IS_MAC ? "• Microphone: macOS asks for permission the first time speak_and_listen listens — click Allow." : "",
|
|
162
216
|
];
|
|
163
217
|
if (done.length)
|
|
164
218
|
lines.push("", "What was done:", ...done.map((d) => `- ${d}`));
|
|
165
|
-
if (install && !done.length && !installing && ready)
|
|
219
|
+
if ((install || opts.kokoro) && !done.length && !installing && ready)
|
|
166
220
|
lines.push("", "Nothing to install — everything was already present.");
|
|
167
221
|
const toInstall = [...new Set(checks.filter((c) => c.status === "missing" && c.brew).map((c) => c.brew))];
|
|
168
222
|
const needsModel = checks.some((c) => c.model && c.status === "missing");
|
|
169
223
|
lines.push("");
|
|
170
224
|
if (ready) {
|
|
171
225
|
lines.push("Next: everything is in place — speak_and_listen is ready to use.");
|
|
226
|
+
const recommended = [...new Set(checks.filter((c) => c.status === "optional" && c.brew).map((c) => c.brew))];
|
|
227
|
+
if (recommended.length && !install && findBrew()) {
|
|
228
|
+
lines.push(`Recommended: ask the user whether to brew install ${recommended.join(" ")} (see • above). ` +
|
|
229
|
+
"Only if they agree, call voice_setup with install=true.");
|
|
230
|
+
}
|
|
231
|
+
if (checks.some((c) => c.kokoro && c.status === "optional") && !opts.kokoro) {
|
|
232
|
+
lines.push(`Optional: ask the user whether to install the Kokoro voice (a one-time install, about 1 GB on disk). ` +
|
|
233
|
+
"Only if they agree, call voice_setup with kokoro=true.");
|
|
234
|
+
}
|
|
172
235
|
if (checks.some((c) => c.label === "Voice" && c.status === "optional")) {
|
|
173
|
-
lines.push("Mention the optional voice tip (•) to the user once — it makes the voice sound far more natural.
|
|
236
|
+
lines.push("Mention the optional voice tip (•) to the user once — it makes the voice sound far more natural. " +
|
|
237
|
+
"If they'd rather not download a macOS voice, the Kokoro voice (voice_setup with kokoro=true, about 1 GB on disk) is the other option. Nothing else to do.");
|
|
174
238
|
}
|
|
175
239
|
}
|
|
176
240
|
else if (installing) {
|
package/dist/texts.js
CHANGED
|
@@ -62,6 +62,8 @@ export const SETUP_TOOL_DESCRIPTION = [
|
|
|
62
62
|
"Call it with install=false (the default) first and tell the user what is missing.",
|
|
63
63
|
"Only call it with install=true after the user agrees: it runs `brew install` for the missing packages",
|
|
64
64
|
"and downloads the speech model once. It never uninstalls or changes anything else.",
|
|
65
|
+
"kokoro=true (also only after the user agrees) installs the optional Kokoro voice: more natural than the built-in one,",
|
|
66
|
+
"runs on this Mac, about 1 GB on disk. Once installed it's used automatically; the built-in voice stays as the fallback.",
|
|
65
67
|
'If it reports INSTALLING, the install continues in the background — wait a minute and call it again to check.',
|
|
66
68
|
].join("\n");
|
|
67
69
|
export const SETUP_PROMPT = [
|
package/dist/voice.js
CHANGED
|
@@ -6,7 +6,7 @@ import { chime, findRecorder, listenForTurn, MIC_PERMISSION_HINT, RECORDER_MISSI
|
|
|
6
6
|
import { CONFIG, debug, DEFAULT_LISTEN_SECONDS, MAX_LISTEN_SECONDS, MAX_SPEAK_CHARS } from "./config.js";
|
|
7
7
|
import { acquireMicLock, MicBusyError } from "./lock.js";
|
|
8
8
|
import { ensureModel } from "./model.js";
|
|
9
|
-
import { SetupError } from "./proc.js";
|
|
9
|
+
import { resetWhichCache, SetupError } from "./proc.js";
|
|
10
10
|
import { prepareSpeech } from "./speech-text.js";
|
|
11
11
|
import { findWhisperCli, findWhisperServer, prewarm, STT_MISSING, transcribe } from "./stt.js";
|
|
12
12
|
/** Everything speak_and_listen needs, checked before saying a word. Never installs anything. */
|
|
@@ -51,11 +51,14 @@ async function turn(textToSpeak, seconds, model, signal, onPhase) {
|
|
|
51
51
|
try {
|
|
52
52
|
const t0 = Date.now();
|
|
53
53
|
onPhase?.("speaking");
|
|
54
|
-
await speak(speech.text, signal);
|
|
54
|
+
const spoken = await speak(speech.text, signal);
|
|
55
55
|
const spoke = Date.now() - t0;
|
|
56
|
+
const fallback = fallbackNote(spoken);
|
|
57
|
+
if (fallback)
|
|
58
|
+
notes.push(fallback);
|
|
56
59
|
if (!model) {
|
|
57
60
|
const text = "(Spoken. The microphone was not opened, because listen was false.)";
|
|
58
|
-
return { ok: true, text, notes: [...notes,
|
|
61
|
+
return { ok: true, text, notes: [...notes, timingNote({ spokeMs: spoke, spoken })] };
|
|
59
62
|
}
|
|
60
63
|
await chime("start");
|
|
61
64
|
onPhase?.("listening");
|
|
@@ -64,11 +67,18 @@ async function turn(textToSpeak, seconds, model, signal, onPhase) {
|
|
|
64
67
|
void chime("stop");
|
|
65
68
|
const t1 = Date.now();
|
|
66
69
|
debug("listen:", heard);
|
|
67
|
-
const timing = (transcribedMs) => timingNote({ spokeMs: spoke, listenedMs: t1 - tListen, talkedSeconds: heard.speechSeconds, transcribedMs });
|
|
70
|
+
const timing = (transcribedMs) => timingNote({ spokeMs: spoke, spoken, listenedMs: t1 - tListen, talkedSeconds: heard.speechSeconds, transcribedMs });
|
|
68
71
|
if (heard.digitalSilence) {
|
|
72
|
+
resetWhichCache(); // if the user fixes it by installing SoX, the next turn picks it up
|
|
73
|
+
const viaFfmpeg = findRecorder()?.kind === "ffmpeg";
|
|
69
74
|
return {
|
|
70
75
|
ok: false,
|
|
71
|
-
text:
|
|
76
|
+
text: viaFfmpeg
|
|
77
|
+
? "The microphone returned pure digital silence. voice-mcp is recording with ffmpeg because SoX isn't installed, " +
|
|
78
|
+
"and ffmpeg may be using a silent or wrong input device (for example after connecting Bluetooth headphones). " +
|
|
79
|
+
"Suggest `brew install sox` (or call voice_setup), then try again. If that doesn't help: " +
|
|
80
|
+
MIC_PERMISSION_HINT
|
|
81
|
+
: `The microphone returned pure digital silence, which usually means microphone access is blocked. ${MIC_PERMISSION_HINT}`,
|
|
72
82
|
notes,
|
|
73
83
|
};
|
|
74
84
|
}
|
|
@@ -98,12 +108,23 @@ async function turn(textToSpeak, seconds, model, signal, onPhase) {
|
|
|
98
108
|
}
|
|
99
109
|
}
|
|
100
110
|
const secs = (ms) => `${(ms / 1000).toFixed(1)} s`;
|
|
111
|
+
/** Kokoro failures already reported in this session: say each once, not on every turn. */
|
|
112
|
+
const reportedFallbacks = new Set();
|
|
113
|
+
function fallbackNote(spoken) {
|
|
114
|
+
if (!spoken.fallback || reportedFallbacks.has(spoken.fallback))
|
|
115
|
+
return null;
|
|
116
|
+
reportedFallbacks.add(spoken.fallback);
|
|
117
|
+
return (`voice-mcp note: the Kokoro voice didn't work (${spoken.fallback}), so the built-in voice spoke instead. ` +
|
|
118
|
+
"Mention it to the user once; voice_setup shows what's wrong.");
|
|
119
|
+
}
|
|
101
120
|
/** One compact line per turn, so "why did that feel slow?" has an answer. */
|
|
102
121
|
export function timingNote(t) {
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
122
|
+
const k = t.spoken?.engine === "kokoro" && t.spoken.firstAudioMs !== undefined ? t.spoken : null;
|
|
123
|
+
const parts = [`spoke ${secs(t.spokeMs)}` + (k ? ` (Kokoro, first sound after ${secs(k.firstAudioMs)}${k.coldStart ? ", voice loaded" : ""})` : "")];
|
|
124
|
+
if (t.listenedMs !== undefined) {
|
|
125
|
+
const talked = t.talkedSeconds ?? 0;
|
|
126
|
+
parts.push(`listened ${secs(t.listenedMs)}` + (talked > 0 ? ` (user talked ${talked.toFixed(1)} s)` : " (no speech)"));
|
|
127
|
+
}
|
|
107
128
|
if (t.transcribedMs !== undefined)
|
|
108
129
|
parts.push(`transcribed ${secs(t.transcribedMs)}`);
|
|
109
130
|
return `voice-mcp timing: ${parts.join(" · ")}`;
|
package/dist/wer.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Word error rate: how far a transcript is from the words that were actually said.
|
|
3
|
+
* Used by `doctor` and the round-trip tests, so "it heard me right" is a number, not a feeling.
|
|
4
|
+
*/
|
|
5
|
+
const ONES = ["zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", "eleven", "twelve", "thirteen", "fourteen", "fifteen", "sixteen", "seventeen", "eighteen", "nineteen"];
|
|
6
|
+
const TENS = ["", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy", "eighty", "ninety"];
|
|
7
|
+
/** 0–999 as words ("42" → "forty two"), so "60" and "sixty" count as the same word. */
|
|
8
|
+
export function numberToWords(n) {
|
|
9
|
+
if (n < 20)
|
|
10
|
+
return ONES[n];
|
|
11
|
+
if (n < 100)
|
|
12
|
+
return TENS[Math.floor(n / 10)] + (n % 10 ? ` ${ONES[n % 10]}` : "");
|
|
13
|
+
return `${ONES[Math.floor(n / 100)]} hundred` + (n % 100 ? ` ${numberToWords(n % 100)}` : "");
|
|
14
|
+
}
|
|
15
|
+
/** Lower-case words without punctuation; "index.ts" → "index ts", "60" → "sixty". */
|
|
16
|
+
export function normalizeWords(text) {
|
|
17
|
+
return text
|
|
18
|
+
.toLowerCase()
|
|
19
|
+
.replace(/\[[^\]]*\]|\([^)]*\)/g, " ") // whisper's [BLANK_AUDIO], (music) …
|
|
20
|
+
.replace(/(\d)[,.](?=\d{3}\b)/g, "$1") // 1,000 → 1000
|
|
21
|
+
.replace(/[^\p{L}\p{N}']+/gu, " ")
|
|
22
|
+
.replace(/'/g, "")
|
|
23
|
+
.split(/\s+/)
|
|
24
|
+
.filter(Boolean)
|
|
25
|
+
.flatMap((w) => (/^\d{1,3}$/.test(w) ? numberToWords(Number(w)).split(" ") : [w]));
|
|
26
|
+
}
|
|
27
|
+
/** Word-level edit distance between what was said (reference) and what was heard (hypothesis). */
|
|
28
|
+
export function wordErrorRate(reference, hypothesis) {
|
|
29
|
+
const ref = normalizeWords(reference);
|
|
30
|
+
const hyp = normalizeWords(hypothesis);
|
|
31
|
+
let prev = Array.from({ length: hyp.length + 1 }, (_, j) => j);
|
|
32
|
+
for (let i = 1; i <= ref.length; i++) {
|
|
33
|
+
const cur = [i];
|
|
34
|
+
for (let j = 1; j <= hyp.length; j++) {
|
|
35
|
+
cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (ref[i - 1] === hyp[j - 1] ? 0 : 1));
|
|
36
|
+
}
|
|
37
|
+
prev = cur;
|
|
38
|
+
}
|
|
39
|
+
const errors = prev[hyp.length];
|
|
40
|
+
return { wer: ref.length ? errors / ref.length : hyp.length ? 1 : 0, errors, words: ref.length };
|
|
41
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mac-voice-mcp",
|
|
3
3
|
"mcpName": "io.github.jeet0007/mac-voice-mcp",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.4.0",
|
|
5
5
|
"description": "Talk with Claude out loud on your Mac: speaks with macOS `say`, listens for one natural conversational turn, and transcribes on-device with whisper.cpp. An MCP server.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -23,6 +23,8 @@
|
|
|
23
23
|
"test": "npm run build && node --test test/*.test.mjs",
|
|
24
24
|
"setup": "node dist/index.js setup",
|
|
25
25
|
"test:voice": "node dist/index.js test",
|
|
26
|
+
"doctor": "node dist/index.js doctor",
|
|
27
|
+
"dev:plugin": "node scripts/dev-plugin.mjs",
|
|
26
28
|
"inspect": "npx -y @modelcontextprotocol/inspector node dist/index.js",
|
|
27
29
|
"prepare": "npm run build",
|
|
28
30
|
"prepublishOnly": "npm test",
|