mac-voice-mcp 0.1.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/LICENSE +21 -0
- package/README.md +268 -0
- package/dist/audio.js +178 -0
- package/dist/config.js +103 -0
- package/dist/endpointer.js +171 -0
- package/dist/index.js +130 -0
- package/dist/model.js +189 -0
- package/dist/proc.js +112 -0
- package/dist/server.js +122 -0
- package/dist/setup.js +183 -0
- package/dist/speech-text.js +104 -0
- package/dist/stt.js +266 -0
- package/dist/texts.js +90 -0
- package/dist/voice.js +74 -0
- package/examples/CLAUDE.md +22 -0
- package/examples/voice-mcp.mdc +27 -0
- package/package.json +67 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn-taking without a model (pure, unit-tested): an energy-based voice-activity
|
|
3
|
+
* detector that adapts to the room's background noise.
|
|
4
|
+
*
|
|
5
|
+
* - waits for speech (≥120 ms above threshold) for up to `startTimeoutMs`
|
|
6
|
+
* - once speaking, ends the turn after `endSilenceMs` of quiet
|
|
7
|
+
* - ignores blips shorter than 300 ms (a cough, a click) and keeps listening
|
|
8
|
+
* - hard stop at `maxMs`
|
|
9
|
+
*/
|
|
10
|
+
export const SAMPLE_RATE = 16000;
|
|
11
|
+
export const FRAME_MS = 30;
|
|
12
|
+
export const FRAME_SAMPLES = (SAMPLE_RATE * FRAME_MS) / 1000; // 480
|
|
13
|
+
export const FRAME_BYTES = FRAME_SAMPLES * 2; // 16-bit mono
|
|
14
|
+
const ONSET_FRAMES = 4; // 120 ms of sound before we believe it's speech
|
|
15
|
+
const MIN_UTTERANCE_MS = 300; // shorter bursts are treated as noise
|
|
16
|
+
const SETTLE_FRAMES = 3; // ignore the first 90 ms while the mic settles
|
|
17
|
+
const FLOOR_WINDOW_FRAMES = 50; // background level = quietest frame of the last 1.5 s
|
|
18
|
+
const STEADY_SPREAD_DB = 2.5; // sound this steady for a whole end-silence window is noise, not speech
|
|
19
|
+
const STEADY_BELOW_SPEECH_DB = 6; // …as long as it's also clearly quieter than the user's voice
|
|
20
|
+
function meanStd(values) {
|
|
21
|
+
if (!values.length)
|
|
22
|
+
return [Number.NaN, Number.NaN];
|
|
23
|
+
const mean = values.reduce((a, b) => a + b, 0) / values.length;
|
|
24
|
+
const variance = values.reduce((a, b) => a + (b - mean) ** 2, 0) / values.length;
|
|
25
|
+
return [mean, Math.sqrt(variance)];
|
|
26
|
+
}
|
|
27
|
+
export class Endpointer {
|
|
28
|
+
opts;
|
|
29
|
+
maxFrames;
|
|
30
|
+
startTimeoutFrames;
|
|
31
|
+
endSilenceFrames;
|
|
32
|
+
historySize;
|
|
33
|
+
/** Recent frame levels in dB, newest last. */
|
|
34
|
+
history = [];
|
|
35
|
+
floorDb = -90;
|
|
36
|
+
speechDb = Number.NaN;
|
|
37
|
+
frames = 0;
|
|
38
|
+
loudRun = 0;
|
|
39
|
+
quietRun = 0;
|
|
40
|
+
voicedFrames = 0;
|
|
41
|
+
started = false;
|
|
42
|
+
everStarted = false;
|
|
43
|
+
/** Frame index where the current utterance began (-1 = none yet). */
|
|
44
|
+
speechStartFrame = -1;
|
|
45
|
+
/** Frame index of the last voiced frame (-1 = none yet). */
|
|
46
|
+
lastVoicedFrame = -1;
|
|
47
|
+
/** False while every sample so far has been exactly zero (a blocked mic). */
|
|
48
|
+
anyNonZero = false;
|
|
49
|
+
constructor(opts) {
|
|
50
|
+
this.opts = opts;
|
|
51
|
+
this.maxFrames = Math.ceil(opts.maxMs / FRAME_MS);
|
|
52
|
+
this.startTimeoutFrames = Math.ceil(Math.min(opts.startTimeoutMs, opts.maxMs) / FRAME_MS);
|
|
53
|
+
this.endSilenceFrames = Math.ceil(opts.endSilenceMs / FRAME_MS);
|
|
54
|
+
this.historySize = Math.max(FLOOR_WINDOW_FRAMES, this.endSilenceFrames, Math.ceil(1000 / FRAME_MS));
|
|
55
|
+
}
|
|
56
|
+
/** Feed one 30 ms frame of 16-bit little-endian PCM; returns a reason once the turn is over. */
|
|
57
|
+
push(frame) {
|
|
58
|
+
const view = new DataView(frame.buffer, frame.byteOffset, frame.byteLength);
|
|
59
|
+
let sumSq = 0;
|
|
60
|
+
const n = frame.byteLength >> 1;
|
|
61
|
+
for (let i = 0; i < n; i++) {
|
|
62
|
+
const v = view.getInt16(i * 2, true);
|
|
63
|
+
if (v !== 0)
|
|
64
|
+
this.anyNonZero = true;
|
|
65
|
+
sumSq += v * v;
|
|
66
|
+
}
|
|
67
|
+
const rms = Math.sqrt(sumSq / Math.max(1, n)) / 32768;
|
|
68
|
+
const db = Math.max(-100, 20 * Math.log10(rms + 1e-9));
|
|
69
|
+
const idx = this.frames++;
|
|
70
|
+
if (idx >= SETTLE_FRAMES) {
|
|
71
|
+
this.history.push(db);
|
|
72
|
+
if (this.history.length > this.historySize)
|
|
73
|
+
this.history.shift();
|
|
74
|
+
}
|
|
75
|
+
// Background level = the quietest frame of the last 1.5 s. Because it keeps
|
|
76
|
+
// updating while the user talks (the gaps between words are quiet), it follows
|
|
77
|
+
// a room that gets louder — a fan, or a laptop mic's automatic gain boosting the
|
|
78
|
+
// noise after speech — as well as one that gets quieter.
|
|
79
|
+
if (this.history.length) {
|
|
80
|
+
this.floorDb = Math.min(-30, Math.max(-90, Math.min(...this.history.slice(-FLOOR_WINDOW_FRAMES))));
|
|
81
|
+
}
|
|
82
|
+
const threshold = Math.max(this.floorDb + this.opts.marginDb, this.opts.minSpeechDb);
|
|
83
|
+
const loud = db > threshold && idx >= SETTLE_FRAMES;
|
|
84
|
+
if (loud) {
|
|
85
|
+
this.loudRun++;
|
|
86
|
+
this.quietRun = 0;
|
|
87
|
+
if (this.started) {
|
|
88
|
+
this.voicedFrames++;
|
|
89
|
+
this.lastVoicedFrame = idx;
|
|
90
|
+
this.speechDb = 0.9 * this.speechDb + 0.1 * db;
|
|
91
|
+
}
|
|
92
|
+
else if (this.loudRun >= ONSET_FRAMES) {
|
|
93
|
+
this.started = true;
|
|
94
|
+
this.everStarted = true;
|
|
95
|
+
this.speechStartFrame = idx - this.loudRun + 1;
|
|
96
|
+
this.voicedFrames = this.loudRun;
|
|
97
|
+
this.lastVoicedFrame = idx;
|
|
98
|
+
this.speechDb = Number.isNaN(this.speechDb) ? db : this.speechDb;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
this.loudRun = 0;
|
|
103
|
+
this.quietRun++;
|
|
104
|
+
}
|
|
105
|
+
if (this.started) {
|
|
106
|
+
// End of turn: a stretch of quiet, or a stretch of steady sound clearly below
|
|
107
|
+
// the voice (speech rises and falls with every syllable; background noise doesn't).
|
|
108
|
+
let ended = this.quietRun >= this.endSilenceFrames;
|
|
109
|
+
if (!ended && this.history.length >= this.endSilenceFrames) {
|
|
110
|
+
const [mean, spread] = meanStd(this.history.slice(-this.endSilenceFrames));
|
|
111
|
+
if (spread < STEADY_SPREAD_DB && mean < this.speechDb - STEADY_BELOW_SPEECH_DB) {
|
|
112
|
+
ended = true;
|
|
113
|
+
this.lastVoicedFrame = Math.max(this.speechStartFrame, idx - this.endSilenceFrames);
|
|
114
|
+
this.voicedFrames = Math.max(0, this.voicedFrames - this.endSilenceFrames + this.quietRun);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (ended) {
|
|
118
|
+
if (this.voicedFrames * FRAME_MS < MIN_UTTERANCE_MS) {
|
|
119
|
+
// Too short to be an answer (cough, click, chair). Keep waiting.
|
|
120
|
+
this.started = false;
|
|
121
|
+
this.everStarted = false;
|
|
122
|
+
this.voicedFrames = 0;
|
|
123
|
+
this.speechStartFrame = -1;
|
|
124
|
+
this.lastVoicedFrame = -1;
|
|
125
|
+
this.speechDb = Number.NaN;
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
return "end-of-turn";
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (this.frames >= this.maxFrames)
|
|
133
|
+
return this.everStarted ? "max-duration" : "no-speech";
|
|
134
|
+
if (!this.everStarted && this.frames >= this.startTimeoutFrames)
|
|
135
|
+
return "no-speech";
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
/** Levels behind the last decision — reported when listening hits its time limit, to help tuning. */
|
|
139
|
+
diagnostics() {
|
|
140
|
+
const [recentDb, recentSpreadDb] = meanStd(this.history.slice(-Math.ceil(1000 / FRAME_MS)));
|
|
141
|
+
return { floorDb: this.floorDb, speechDb: this.speechDb, recentDb, recentSpreadDb };
|
|
142
|
+
}
|
|
143
|
+
get speechSeconds() {
|
|
144
|
+
return (this.voicedFrames * FRAME_MS) / 1000;
|
|
145
|
+
}
|
|
146
|
+
/** Frame range worth transcribing: the utterance plus `padMs` either side. */
|
|
147
|
+
trimRange(totalFrames, padMs = 300) {
|
|
148
|
+
const pad = Math.round(padMs / FRAME_MS);
|
|
149
|
+
const first = this.speechStartFrame >= 0 ? Math.max(0, this.speechStartFrame - pad) : 0;
|
|
150
|
+
const last = this.lastVoicedFrame >= 0 ? Math.min(totalFrames, this.lastVoicedFrame + 1 + pad) : totalFrames;
|
|
151
|
+
return [first, last];
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/** 44-byte header for 16 kHz mono 16-bit PCM WAV. */
|
|
155
|
+
export function wavHeader(dataBytes) {
|
|
156
|
+
const h = Buffer.alloc(44);
|
|
157
|
+
h.write("RIFF", 0, "ascii");
|
|
158
|
+
h.writeUInt32LE(36 + dataBytes, 4);
|
|
159
|
+
h.write("WAVE", 8, "ascii");
|
|
160
|
+
h.write("fmt ", 12, "ascii");
|
|
161
|
+
h.writeUInt32LE(16, 16);
|
|
162
|
+
h.writeUInt16LE(1, 20); // PCM
|
|
163
|
+
h.writeUInt16LE(1, 22); // mono
|
|
164
|
+
h.writeUInt32LE(SAMPLE_RATE, 24);
|
|
165
|
+
h.writeUInt32LE(SAMPLE_RATE * 2, 28);
|
|
166
|
+
h.writeUInt16LE(2, 32);
|
|
167
|
+
h.writeUInt16LE(16, 34);
|
|
168
|
+
h.write("data", 36, "ascii");
|
|
169
|
+
h.writeUInt32LE(dataBytes, 40);
|
|
170
|
+
return h;
|
|
171
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* mac-voice-mcp — a local voice bridge for MCP clients.
|
|
4
|
+
*
|
|
5
|
+
* Tools
|
|
6
|
+
* speak_and_listen(text_to_speak, listen_seconds?)
|
|
7
|
+
* 1. speaks the text with native TTS (`say` on macOS)
|
|
8
|
+
* 2. listens like a conversation turn: waits for the user to start talking and
|
|
9
|
+
* stops ~1.2 s after they finish (adaptive voice-activity detection)
|
|
10
|
+
* 3. transcribes on-device with whisper.cpp (a warm whisper-server keeps the
|
|
11
|
+
* model loaded between turns; whisper-cli is the fallback)
|
|
12
|
+
* 4. returns the transcript to the model
|
|
13
|
+
* voice_setup(install?)
|
|
14
|
+
* checks what's already installed and — only with the user's OK — installs
|
|
15
|
+
* what's missing (Homebrew packages, the speech model). Reuses everything.
|
|
16
|
+
*
|
|
17
|
+
* Everything runs locally. No audio leaves the machine.
|
|
18
|
+
* stdout is reserved for the MCP protocol; all logging goes to stderr.
|
|
19
|
+
*
|
|
20
|
+
* Module map
|
|
21
|
+
* config.ts settings (env vars), logging, PATH fix-up for GUI apps
|
|
22
|
+
* proc.ts which / run helpers, error types
|
|
23
|
+
* speech-text.ts screen text → ear text; transcript clean-up (pure)
|
|
24
|
+
* endpointer.ts turn-taking voice-activity detection (pure)
|
|
25
|
+
* audio.ts TTS, chimes, streaming microphone capture
|
|
26
|
+
* model.ts find / symlink / download the whisper model
|
|
27
|
+
* stt.ts warm whisper-server + whisper-cli fallback
|
|
28
|
+
* setup.ts requirement checks and consent-based installs
|
|
29
|
+
* voice.ts the speak → listen → transcribe round trip
|
|
30
|
+
* texts.ts everything the model reads (rules, instructions, prompts)
|
|
31
|
+
* server.ts MCP tools and prompts
|
|
32
|
+
*/
|
|
33
|
+
import { createInterface } from "node:readline/promises";
|
|
34
|
+
import { DEFAULT_LISTEN_SECONDS, envNum, log, PKG } from "./config.js";
|
|
35
|
+
import { startServer } from "./server.js";
|
|
36
|
+
import { runSetupFlow } from "./setup.js";
|
|
37
|
+
import { stopWhisperServer } from "./stt.js";
|
|
38
|
+
import { speakAndListen } from "./voice.js";
|
|
39
|
+
function printHelp() {
|
|
40
|
+
console.error(`${PKG.name} v${PKG.version} — local voice bridge MCP server
|
|
41
|
+
|
|
42
|
+
Usage:
|
|
43
|
+
npx -y ${PKG.name} Start the MCP server on stdio (what MCP clients run)
|
|
44
|
+
npx -y ${PKG.name} setup Check what's installed; offers to install what's missing
|
|
45
|
+
npx -y ${PKG.name} setup --install Install what's missing without asking (brew + model download)
|
|
46
|
+
npx -y ${PKG.name} test ["text"] Speak, listen for one turn and print the transcript
|
|
47
|
+
npx -y ${PKG.name} --version
|
|
48
|
+
|
|
49
|
+
Environment variables (all optional):
|
|
50
|
+
VOICE_MCP_WHISPER_MODEL whisper.cpp model name (default: base.en)
|
|
51
|
+
VOICE_MCP_WHISPER_MODEL_PATH use this ggml model file
|
|
52
|
+
VOICE_MCP_MODEL_SEARCH_PATHS extra folders to look in for an existing model (":"-separated)
|
|
53
|
+
VOICE_MCP_LANGUAGE spoken language: en, th, de, … or auto
|
|
54
|
+
VOICE_MCP_WHISPER_PROMPT words to bias recognition toward (names, jargon)
|
|
55
|
+
VOICE_MCP_VOICE / _RATE macOS say voice and words-per-minute
|
|
56
|
+
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)
|
|
58
|
+
VOICE_MCP_SPEECH_MARGIN_DB how far above room noise counts as speech (default 12)
|
|
59
|
+
VOICE_MCP_WHISPER_SERVER 0 to always use whisper-cli (no warm server)
|
|
60
|
+
VOICE_MCP_SERVER_IDLE_MINUTES stop the warm server after this idle time (default 15)
|
|
61
|
+
VOICE_MCP_CHIME 0 to disable the mic open/close sounds
|
|
62
|
+
VOICE_MCP_DEBUG 1 for verbose logs and timings (stderr)`);
|
|
63
|
+
}
|
|
64
|
+
async function runSetupCli(args) {
|
|
65
|
+
console.error(`${PKG.name} v${PKG.version} on ${process.platform}/${process.arch}, Node ${process.version}\n`);
|
|
66
|
+
const force = args.includes("--install") || args.includes("-y");
|
|
67
|
+
let outcome = await runSetupFlow(force, (m) => console.error(` … ${m}`));
|
|
68
|
+
// Interactive terminal: offer to install right away.
|
|
69
|
+
if (!force && !outcome.ready && !outcome.installing && process.stdin.isTTY && /ask the user whether/.test(outcome.report)) {
|
|
70
|
+
console.error(outcome.report.replace(/\nNext:.*$/s, ""));
|
|
71
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
72
|
+
const answer = (await rl.question("\nInstall the missing pieces now? [Y/n] ")).trim().toLowerCase();
|
|
73
|
+
rl.close();
|
|
74
|
+
if (answer !== "" && !answer.startsWith("y"))
|
|
75
|
+
return 1;
|
|
76
|
+
outcome = await runSetupFlow(true, (m) => console.error(` … ${m}`));
|
|
77
|
+
}
|
|
78
|
+
// In a terminal we can simply wait for background installs to finish.
|
|
79
|
+
while (outcome.installing)
|
|
80
|
+
outcome = await runSetupFlow(false, (m) => console.error(` … ${m}`));
|
|
81
|
+
console.error(outcome.report);
|
|
82
|
+
return outcome.ready ? 0 : 1;
|
|
83
|
+
}
|
|
84
|
+
async function runTestCli(text) {
|
|
85
|
+
const prompt = text || "Voice bridge test. Say something after the chime, and I'll print what I heard.";
|
|
86
|
+
try {
|
|
87
|
+
const result = await speakAndListen(prompt, envNum("VOICE_MCP_TEST_SECONDS", DEFAULT_LISTEN_SECONDS), undefined, (p) => console.error(` … ${p}`));
|
|
88
|
+
process.stdout.write(result.text + "\n");
|
|
89
|
+
for (const note of result.notes)
|
|
90
|
+
console.error(note);
|
|
91
|
+
return result.ok ? 0 : 1;
|
|
92
|
+
}
|
|
93
|
+
catch (e) {
|
|
94
|
+
console.error(e instanceof Error ? e.message : e);
|
|
95
|
+
return 1;
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
stopWhisperServer();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
async function main() {
|
|
102
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
103
|
+
switch (cmd) {
|
|
104
|
+
case undefined:
|
|
105
|
+
case "serve":
|
|
106
|
+
return startServer();
|
|
107
|
+
case "setup":
|
|
108
|
+
case "doctor":
|
|
109
|
+
process.exit(await runSetupCli(rest));
|
|
110
|
+
case "test":
|
|
111
|
+
process.exit(await runTestCli(rest.join(" ").trim()));
|
|
112
|
+
case "-v":
|
|
113
|
+
case "--version":
|
|
114
|
+
console.log(PKG.version);
|
|
115
|
+
return;
|
|
116
|
+
case "-h":
|
|
117
|
+
case "--help":
|
|
118
|
+
case "help":
|
|
119
|
+
printHelp();
|
|
120
|
+
return;
|
|
121
|
+
default:
|
|
122
|
+
console.error(`Unknown command: ${cmd}\n`);
|
|
123
|
+
printHelp();
|
|
124
|
+
process.exit(2);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
main().catch((err) => {
|
|
128
|
+
log("Fatal:", err instanceof Error ? (err.stack ?? err.message) : err);
|
|
129
|
+
process.exit(1);
|
|
130
|
+
});
|
package/dist/model.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The whisper.cpp speech model: find it without redoing work, download only with consent.
|
|
3
|
+
*
|
|
4
|
+
* 1. VOICE_MCP_WHISPER_MODEL_PATH
|
|
5
|
+
* 2. our cache (a real file, or a symlink made by an earlier run)
|
|
6
|
+
* 3. the same model elsewhere on this machine → symlinked into the cache
|
|
7
|
+
* 4. a one-time download — only via voice_setup(install=true) / `setup`
|
|
8
|
+
*/
|
|
9
|
+
import { closeSync, createWriteStream, existsSync, lstatSync, openSync, readSync, statSync } from "node:fs";
|
|
10
|
+
import { mkdir, rename, rm, symlink, unlink } from "node:fs/promises";
|
|
11
|
+
import os from "node:os";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { Readable } from "node:stream";
|
|
14
|
+
import { pipeline } from "node:stream/promises";
|
|
15
|
+
import { CONFIG, debug, IS_MAC, log } from "./config.js";
|
|
16
|
+
import { run, SetupError } from "./proc.js";
|
|
17
|
+
/** Approximate download sizes, for the setup report. */
|
|
18
|
+
export const MODEL_SIZES_MB = {
|
|
19
|
+
"tiny.en": 75, tiny: 75, "base.en": 142, base: 142, "small.en": 466, small: 466,
|
|
20
|
+
"medium.en": 1500, medium: 1500, "large-v3-turbo": 1620, "large-v3-turbo-q5_0": 547, "large-v3": 3100,
|
|
21
|
+
};
|
|
22
|
+
/** How the current model was obtained — shown in the setup report. */
|
|
23
|
+
export let modelSource = "";
|
|
24
|
+
export function modelFile() {
|
|
25
|
+
if (CONFIG.modelPath)
|
|
26
|
+
return CONFIG.modelPath;
|
|
27
|
+
if (!/^[A-Za-z0-9._-]+$/.test(CONFIG.modelName)) {
|
|
28
|
+
throw new SetupError(`Invalid VOICE_MCP_WHISPER_MODEL "${CONFIG.modelName}".`);
|
|
29
|
+
}
|
|
30
|
+
return path.join(CONFIG.modelsDir, `ggml-${CONFIG.modelName}.bin`);
|
|
31
|
+
}
|
|
32
|
+
/** whisper.cpp ggml model files start with the magic 0x67676d6c ("ggml"). */
|
|
33
|
+
export function isGgmlModel(file) {
|
|
34
|
+
let fd;
|
|
35
|
+
try {
|
|
36
|
+
if (statSync(file).size < 1_000_000)
|
|
37
|
+
return false;
|
|
38
|
+
fd = openSync(file, "r");
|
|
39
|
+
const head = Buffer.alloc(4);
|
|
40
|
+
return readSync(fd, head, 0, 4, 0) === 4 && head.readUInt32LE(0) === 0x67676d6c;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
if (fd !== undefined)
|
|
47
|
+
closeSync(fd);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Look for the same model elsewhere before downloading: whisper.cpp checkouts,
|
|
52
|
+
* Homebrew's share folder, other tools' caches, and (on macOS) Spotlight.
|
|
53
|
+
*/
|
|
54
|
+
async function findExistingModel(fileName) {
|
|
55
|
+
const home = os.homedir();
|
|
56
|
+
const dirs = [
|
|
57
|
+
...CONFIG.modelSearchPaths,
|
|
58
|
+
"/opt/homebrew/share/whisper-cpp",
|
|
59
|
+
"/opt/homebrew/share/whisper-cpp/models",
|
|
60
|
+
"/usr/local/share/whisper-cpp",
|
|
61
|
+
"/usr/local/share/whisper-cpp/models",
|
|
62
|
+
path.join(home, ".cache", "claude-voice-mcp", "models"), // this project's cache before it was renamed
|
|
63
|
+
path.join(home, ".cache", "whisper.cpp"),
|
|
64
|
+
path.join(home, ".cache", "whisper"),
|
|
65
|
+
path.join(home, ".local", "share", "whisper.cpp", "models"),
|
|
66
|
+
path.join(home, "Library", "Application Support", "whisper.cpp"),
|
|
67
|
+
...["", "src", "code", "Code", "dev", "Developer", "projects", "Projects", "workspace", "git", "repos", "GitHub"].map((d) => path.join(home, d, "whisper.cpp", "models")),
|
|
68
|
+
];
|
|
69
|
+
for (const dir of dirs) {
|
|
70
|
+
const candidate = path.join(dir, fileName);
|
|
71
|
+
if (existsSync(candidate) && isGgmlModel(candidate))
|
|
72
|
+
return candidate;
|
|
73
|
+
}
|
|
74
|
+
if (IS_MAC && existsSync("/usr/bin/mdfind")) {
|
|
75
|
+
try {
|
|
76
|
+
const r = await run("/usr/bin/mdfind", ["-name", fileName], { timeoutMs: 4000 });
|
|
77
|
+
for (const line of r.stdout.split("\n").map((l) => l.trim())) {
|
|
78
|
+
if (path.basename(line) === fileName && !line.startsWith(CONFIG.modelsDir) && isGgmlModel(line))
|
|
79
|
+
return line;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
/* Spotlight unavailable — fine */
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
/** Point our cache at an existing file (symlink), so later lookups are instant. */
|
|
89
|
+
async function linkIntoCache(target, linkPath) {
|
|
90
|
+
try {
|
|
91
|
+
await mkdir(path.dirname(linkPath), { recursive: true });
|
|
92
|
+
await symlink(target, linkPath);
|
|
93
|
+
return linkPath;
|
|
94
|
+
}
|
|
95
|
+
catch (e) {
|
|
96
|
+
debug("symlink failed, using the file in place:", e);
|
|
97
|
+
return target;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
let resolvedModel = null;
|
|
101
|
+
let modelDownload = null;
|
|
102
|
+
/** Find the model on this machine (steps 1–3). Returns null if it isn't here at all. Never downloads. */
|
|
103
|
+
export async function locateModel() {
|
|
104
|
+
if (resolvedModel && existsSync(resolvedModel))
|
|
105
|
+
return resolvedModel;
|
|
106
|
+
if (CONFIG.modelPath) {
|
|
107
|
+
if (!existsSync(CONFIG.modelPath)) {
|
|
108
|
+
throw new SetupError(`VOICE_MCP_WHISPER_MODEL_PATH does not exist: ${CONFIG.modelPath}`);
|
|
109
|
+
}
|
|
110
|
+
modelSource = "from VOICE_MCP_WHISPER_MODEL_PATH";
|
|
111
|
+
return (resolvedModel = CONFIG.modelPath);
|
|
112
|
+
}
|
|
113
|
+
const file = modelFile();
|
|
114
|
+
if (existsSync(file) && isGgmlModel(file)) {
|
|
115
|
+
modelSource = lstatSync(file).isSymbolicLink() ? "reused (symlinked from an existing copy)" : "cached";
|
|
116
|
+
return (resolvedModel = file);
|
|
117
|
+
}
|
|
118
|
+
// A dangling symlink (the original was deleted) or a corrupt file: clear it.
|
|
119
|
+
try {
|
|
120
|
+
if (lstatSync(file))
|
|
121
|
+
await unlink(file);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
/* nothing there */
|
|
125
|
+
}
|
|
126
|
+
const existing = await findExistingModel(path.basename(file));
|
|
127
|
+
if (existing) {
|
|
128
|
+
log(`Reusing existing model ${existing}`);
|
|
129
|
+
modelSource = `reused ${existing}`;
|
|
130
|
+
return (resolvedModel = await linkIntoCache(existing, file));
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
/** locateModel(), plus — only when allowed — a one-time download into the cache. */
|
|
135
|
+
export async function ensureModel(opts) {
|
|
136
|
+
const found = await locateModel();
|
|
137
|
+
if (found)
|
|
138
|
+
return found;
|
|
139
|
+
if (!opts.download) {
|
|
140
|
+
throw new SetupError(`The speech model "${CONFIG.modelName}" isn't on this computer yet. ` +
|
|
141
|
+
"Call the voice_setup tool (with install=true once the user agrees) to download it.");
|
|
142
|
+
}
|
|
143
|
+
modelDownload ??= downloadModel(modelFile()).finally(() => {
|
|
144
|
+
modelDownload = null;
|
|
145
|
+
});
|
|
146
|
+
const downloaded = await modelDownload;
|
|
147
|
+
modelSource = "downloaded";
|
|
148
|
+
return (resolvedModel = downloaded);
|
|
149
|
+
}
|
|
150
|
+
/** Is a download currently running (e.g. started by an earlier voice_setup call)? */
|
|
151
|
+
export const isModelDownloading = () => modelDownload !== null;
|
|
152
|
+
async function downloadModel(dest) {
|
|
153
|
+
await mkdir(path.dirname(dest), { recursive: true });
|
|
154
|
+
const url = `${CONFIG.modelBaseUrl}/ggml-${CONFIG.modelName}.bin`;
|
|
155
|
+
log(`Downloading whisper model "${CONFIG.modelName}" → ${dest}`);
|
|
156
|
+
const res = await fetch(url, { redirect: "follow" });
|
|
157
|
+
if (!res.ok || !res.body) {
|
|
158
|
+
throw new SetupError(`Could not download whisper model from ${url} (HTTP ${res.status}). ` +
|
|
159
|
+
"Check the model name, or download it manually and set VOICE_MCP_WHISPER_MODEL_PATH.");
|
|
160
|
+
}
|
|
161
|
+
const total = Number(res.headers.get("content-length")) || 0;
|
|
162
|
+
const tmp = `${dest}.${process.pid}.part`;
|
|
163
|
+
let received = 0;
|
|
164
|
+
let lastLogged = -1;
|
|
165
|
+
try {
|
|
166
|
+
await pipeline(Readable.fromWeb(res.body), async function* (source) {
|
|
167
|
+
for await (const chunk of source) {
|
|
168
|
+
received += chunk.length;
|
|
169
|
+
if (total) {
|
|
170
|
+
const pct = Math.floor((received / total) * 10) * 10;
|
|
171
|
+
if (pct > lastLogged) {
|
|
172
|
+
lastLogged = pct;
|
|
173
|
+
log(` model download ${pct}% (${(received / 1e6).toFixed(0)} / ${(total / 1e6).toFixed(0)} MB)`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
yield chunk;
|
|
177
|
+
}
|
|
178
|
+
}, createWriteStream(tmp));
|
|
179
|
+
if (!isGgmlModel(tmp))
|
|
180
|
+
throw new Error("downloaded file is not a whisper.cpp ggml model");
|
|
181
|
+
await rename(tmp, dest);
|
|
182
|
+
}
|
|
183
|
+
catch (e) {
|
|
184
|
+
await rm(tmp, { force: true });
|
|
185
|
+
throw e;
|
|
186
|
+
}
|
|
187
|
+
log(`Model ready: ${dest}`);
|
|
188
|
+
return dest;
|
|
189
|
+
}
|
package/dist/proc.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/** Child-process helpers: PATH lookup, a promise-based runner, and error types. */
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { accessSync, constants as fsConstants, statSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { debug, IS_WIN } from "./config.js";
|
|
6
|
+
/** Something the user has to install or configure — the message says how. */
|
|
7
|
+
export class SetupError extends Error {
|
|
8
|
+
name = "SetupError";
|
|
9
|
+
}
|
|
10
|
+
export class CancelledError extends Error {
|
|
11
|
+
name = "CancelledError";
|
|
12
|
+
constructor() {
|
|
13
|
+
super("Cancelled by client");
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export function isExecutable(file) {
|
|
17
|
+
try {
|
|
18
|
+
if (!statSync(file).isFile())
|
|
19
|
+
return false;
|
|
20
|
+
accessSync(file, fsConstants.X_OK);
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const whichCache = new Map();
|
|
28
|
+
/** Forget cached lookups — needed right after `brew install` adds new binaries. */
|
|
29
|
+
export function resetWhichCache() {
|
|
30
|
+
whichCache.clear();
|
|
31
|
+
}
|
|
32
|
+
/** Resolve a binary on PATH (cached). Absolute paths are checked as-is. */
|
|
33
|
+
export function which(bin) {
|
|
34
|
+
if (whichCache.has(bin))
|
|
35
|
+
return whichCache.get(bin);
|
|
36
|
+
let found = null;
|
|
37
|
+
if (path.isAbsolute(bin)) {
|
|
38
|
+
found = isExecutable(bin) ? bin : null;
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
const exts = IS_WIN ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";").concat([""]) : [""];
|
|
42
|
+
outer: for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
|
|
43
|
+
if (!dir)
|
|
44
|
+
continue;
|
|
45
|
+
for (const ext of exts) {
|
|
46
|
+
const candidate = path.join(dir, bin + ext);
|
|
47
|
+
if (isExecutable(candidate)) {
|
|
48
|
+
found = candidate;
|
|
49
|
+
break outer;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
whichCache.set(bin, found);
|
|
55
|
+
return found;
|
|
56
|
+
}
|
|
57
|
+
/** Every child we start, so shutdown can stop them all. */
|
|
58
|
+
export const activeChildren = new Set();
|
|
59
|
+
export function run(cmd, args, opts = {}) {
|
|
60
|
+
return new Promise((resolve, reject) => {
|
|
61
|
+
if (opts.signal?.aborted)
|
|
62
|
+
return reject(new CancelledError());
|
|
63
|
+
debug("exec:", cmd, args.join(" "));
|
|
64
|
+
const child = spawn(cmd, args, {
|
|
65
|
+
stdio: [opts.input !== undefined ? "pipe" : "ignore", "pipe", "pipe"],
|
|
66
|
+
windowsHide: true,
|
|
67
|
+
env: opts.env ? { ...process.env, ...opts.env } : process.env,
|
|
68
|
+
});
|
|
69
|
+
activeChildren.add(child);
|
|
70
|
+
let stdout = "";
|
|
71
|
+
let stderr = "";
|
|
72
|
+
let timedOut = false;
|
|
73
|
+
child.stdout?.setEncoding("utf8").on("data", (d) => (stdout += d));
|
|
74
|
+
child.stderr?.setEncoding("utf8").on("data", (d) => (stderr += d));
|
|
75
|
+
const kill = () => {
|
|
76
|
+
child.kill(opts.killSignal ?? "SIGTERM");
|
|
77
|
+
setTimeout(() => child.exitCode === null && child.kill("SIGKILL"), 2000).unref();
|
|
78
|
+
};
|
|
79
|
+
const timer = opts.timeoutMs
|
|
80
|
+
? setTimeout(() => {
|
|
81
|
+
timedOut = true;
|
|
82
|
+
kill();
|
|
83
|
+
}, opts.timeoutMs)
|
|
84
|
+
: undefined;
|
|
85
|
+
opts.signal?.addEventListener("abort", kill, { once: true });
|
|
86
|
+
const cleanup = () => {
|
|
87
|
+
if (timer)
|
|
88
|
+
clearTimeout(timer);
|
|
89
|
+
opts.signal?.removeEventListener("abort", kill);
|
|
90
|
+
activeChildren.delete(child);
|
|
91
|
+
};
|
|
92
|
+
child.on("error", (err) => {
|
|
93
|
+
cleanup();
|
|
94
|
+
reject(err);
|
|
95
|
+
});
|
|
96
|
+
child.on("close", (code) => {
|
|
97
|
+
cleanup();
|
|
98
|
+
if (opts.signal?.aborted)
|
|
99
|
+
reject(new CancelledError());
|
|
100
|
+
else
|
|
101
|
+
resolve({ code, stdout, stderr, timedOut });
|
|
102
|
+
});
|
|
103
|
+
if (opts.input !== undefined && child.stdin) {
|
|
104
|
+
child.stdin.on("error", () => { }); // ignore EPIPE if the process exits early
|
|
105
|
+
child.stdin.end(opts.input);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
export function tail(s, lines = 6) {
|
|
110
|
+
return s.trim().split("\n").slice(-lines).join("\n");
|
|
111
|
+
}
|
|
112
|
+
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|