mac-voice-mcp 0.4.1 → 0.4.2

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 CHANGED
@@ -5,6 +5,16 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.4.2] - 2026-10-11
9
+
10
+ ### Changed
11
+ - **Docs and the voice-help skill now suggest `small.en` for noisy rooms,** with measured numbers: on real recordings with background noise it got 8% of words wrong instead of 12% with `base.en`, the same in a quiet room, at about 0.3 s per reply. `base.en` stays the default, so nobody gets a 466 MB download they didn't ask for.
12
+
13
+ ### Fixed
14
+ - **`doctor` explains a voice that produces no audio.** Sometimes macOS `say` finishes without error but writes almost no sound (0.1 s for a whole sentence), for example when a voice's speech data is missing. `doctor` used to report that as "0% of words right". Now it tries once more, then says which voice wrote no audio and how to pick another (`VOICE_MCP_VOICE`). This was also behind the speech round trip failing on some CI runs, which now use a fixed classic voice.
15
+ - **A cough or a fan no longer counts as your answer.** For a sound with no words, whisper writes a note like `[gunshot]` (a cough), `[APPLAUSE]` (typing) or `[sound of running]` (a fan), and those notes were passed to Claude as if you'd said them. Now every sound note is dropped, and Claude is told it heard a sound but no words. On 10 noise-only clips (cough, typing, fan, hum, music, a door, breathing, silence), all 10 now come back as no speech.
16
+ - **Sentences whisper invents from videos are dropped**, such as "You can find the link in the description below." or "Thanks for watching!". Only whole sentences that nobody says to a coding assistant; "Thank you." and "Bye." stay.
17
+
8
18
  ## [0.4.1] - 2026-10-10
9
19
 
10
20
  ### Added
@@ -111,7 +121,8 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
111
121
  - `voice_setup`: checks first, installs only what's missing, and only after you agree.
112
122
  - The `setup` and `voice_mode` prompts.
113
123
 
114
- [Unreleased]: https://github.com/jeet0007/mac-voice-mcp/compare/v0.4.1...HEAD
124
+ [Unreleased]: https://github.com/jeet0007/mac-voice-mcp/compare/v0.4.2...HEAD
125
+ [0.4.2]: https://github.com/jeet0007/mac-voice-mcp/compare/v0.4.1...v0.4.2
115
126
  [0.4.1]: https://github.com/jeet0007/mac-voice-mcp/compare/v0.4.0...v0.4.1
116
127
  [0.4.0]: https://github.com/jeet0007/mac-voice-mcp/compare/v0.3.1...v0.4.0
117
128
  [0.3.1]: https://github.com/jeet0007/mac-voice-mcp/compare/v0.3.0...v0.3.1
package/dist/audio.js CHANGED
@@ -1,10 +1,11 @@
1
1
  /** Speaking (native TTS), the mic chimes, and listening for one conversational turn. */
2
2
  import { spawn } from "node:child_process";
3
3
  import { existsSync } from "node:fs";
4
- import { writeFile } from "node:fs/promises";
4
+ import { readFile, writeFile } from "node:fs/promises";
5
5
  import { CONFIG, debug, IS_MAC, IS_WIN, log } from "./config.js";
6
6
  import { Endpointer, FRAME_BYTES, FRAME_MS, wavHeader } from "./endpointer.js";
7
7
  import { KokoroError, kokoroEnabled, speakKokoro, synthesizeKokoroToFile } from "./kokoro.js";
8
+ import { wavSeconds } from "./pcm.js";
8
9
  import { activeChildren, CancelledError, run, SetupError, tail, which } from "./proc.js";
9
10
  // ---------------------------------------------------------------------------
10
11
  // Text-to-speech
@@ -138,20 +139,33 @@ export async function synthesizeToFile(text, outFile) {
138
139
  const bin = findTts();
139
140
  if (!bin)
140
141
  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.");
142
+ const { voice } = IS_MAC ? await chooseVoice() : { voice: undefined };
143
+ const once = async () => {
144
+ let result;
145
+ if (IS_MAC) {
146
+ const args = [...(voice ? ["-v", voice] : []), "-o", outFile, "--file-format=WAVE", "--data-format=LEI16@16000", "-f", "-"];
147
+ result = await run(bin, args, { input: text, timeoutMs: 60_000 });
148
+ }
149
+ else if (/espeak/.test(bin)) {
150
+ result = await run(bin, ["-w", outFile, "--stdin"], { input: text, timeoutMs: 60_000 });
151
+ }
152
+ else {
153
+ throw new SetupError("Writing speech to a file needs macOS `say` or espeak-ng.");
154
+ }
155
+ if (result.code !== 0)
156
+ throw new Error(`Text-to-speech to file failed (exit ${result.code}): ${tail(result.stderr) || "no output"}`);
157
+ return wavSeconds(await readFile(outFile));
158
+ };
159
+ // Sometimes `say` exits fine but writes (almost) no audio, e.g. when the voice's speech data
160
+ // isn't available. Try once more, then say so plainly instead of handing silence to whisper.
161
+ const expected = text.trim().split(/\s+/).length > 2;
162
+ let seconds = await once();
163
+ if (expected && seconds < 0.3)
164
+ seconds = await once();
165
+ if (expected && seconds < 0.3) {
166
+ throw new Error(`Text-to-speech wrote only ${seconds.toFixed(2)} s of audio for "${text.slice(0, 40)}…" with ${voice ? `the voice "${voice}"` : "the system voice"}. ` +
167
+ "That voice may be missing its speech data: pick another with VOICE_MCP_VOICE (list them with `say -v '?'`).");
152
168
  }
153
- if (result.code !== 0)
154
- throw new Error(`Text-to-speech to file failed (exit ${result.code}): ${tail(result.stderr) || "no output"}`);
155
169
  return { engine: "built-in" };
156
170
  }
157
171
  /** A short, quiet cue that the mic just opened ("start") or closed ("stop"). macOS only. */
package/dist/pcm.js CHANGED
@@ -29,6 +29,23 @@ export function resamplePcm16(pcm, fromRate, toRate) {
29
29
  }
30
30
  return out;
31
31
  }
32
+ /** Seconds of audio in a 16-bit mono WAV file's data chunk (`say` may add chunks before it). 0 if there is none. */
33
+ export function wavSeconds(wav) {
34
+ if (wav.length < 12 || wav.toString("ascii", 0, 4) !== "RIFF")
35
+ return 0;
36
+ let rate = 16_000;
37
+ let off = 12;
38
+ while (off + 8 <= wav.length) {
39
+ const id = wav.toString("ascii", off, off + 4);
40
+ const size = wav.readUInt32LE(off + 4);
41
+ if (id === "fmt " && off + 16 <= wav.length)
42
+ rate = wav.readUInt32LE(off + 12) || rate;
43
+ if (id === "data")
44
+ return Math.min(size, wav.length - off - 8) / 2 / rate;
45
+ off += 8 + size + (size % 2);
46
+ }
47
+ return 0;
48
+ }
32
49
  /** A 44-byte WAV header for 16-bit mono PCM. */
33
50
  export function wavHeaderFor(dataBytes, sampleRate) {
34
51
  const h = Buffer.alloc(44);
@@ -112,13 +112,39 @@ export function prepareSpeech(input, opts = {}) {
112
112
  return { text: s, notes: noteList };
113
113
  }
114
114
  /** Remove timestamps and non-speech markers like [BLANK_AUDIO] from whisper output. */
115
+ /**
116
+ * whisper's notes about sounds rather than words: [BLANK_AUDIO], [MUSIC PLAYING], [gunshot] (for a
117
+ * cough), [APPLAUSE] (for typing), (sound of running) (for a fan), *laughs*, ♪. Never something the user said.
118
+ */
119
+ const SOUND_NOTES = /\[[^\]]*\]|\([^)]*\)|\*[^*\n]+\*|[♪♫]+/g;
120
+ /**
121
+ * Sentences whisper invents from the videos it learned from, typically on noise or a clipped start.
122
+ * Only whole sentences that nobody says to a coding assistant; "Thank you." and "Bye." are real answers.
123
+ */
124
+ const STOCK_SENTENCES = [
125
+ /^(?:thanks|thank you)(?: (?:so|very) much)? for (?:watching|listening)(?: and see you next time)?$/,
126
+ /^(?:please |don'?t forget to )?(?:like and )?subscribe(?: to (?:my|our|the) channel)?$/,
127
+ /^(?:you can )?find the links? in the description(?: below)?$/,
128
+ /^see you in the next (?:video|episode)$/,
129
+ /^(?:subtitles|captions|transcription|translated|transcribed)(?: by| provided by)? .*$/,
130
+ ];
131
+ function isStockSentence(sentence) {
132
+ const s = sentence.toLowerCase().replace(/[.!?,"“”]/g, "").replace(/\s+/g, " ").trim();
133
+ return STOCK_SENTENCES.some((re) => re.test(s));
134
+ }
135
+ /** whisper's raw output → just the words: no timestamps, sound notes or invented stock sentences. */
115
136
  export function cleanTranscript(raw) {
116
- return raw
137
+ const text = raw
117
138
  .split("\n")
118
139
  .map((l) => l.replace(/^\s*\[[\d:.\s\->]+\]\s*/, "")) // stray timestamps
119
140
  .join(" ")
120
- .replace(/\[(?:BLANK_AUDIO|MUSIC|NOISE|SILENCE|INAUDIBLE|_[A-Z_]+_)[^\]]*\]/gi, " ")
121
- .replace(/\[\s*(?:silence|music|noise|inaudible)\s*\]/gi, " ")
141
+ .replace(SOUND_NOTES, " ")
142
+ .replace(/\s+/g, " ")
143
+ .trim();
144
+ const sentences = text.match(/.+?(?:[.!?]+(?=\s|$)|$)/g) ?? []; // a "." inside "1.2.3" or "Amara.org" doesn't end a sentence
145
+ return sentences
146
+ .filter((s) => !isStockSentence(s))
147
+ .join("")
122
148
  .replace(/\s+/g, " ")
123
149
  .trim();
124
150
  }
package/dist/voice.js CHANGED
@@ -91,8 +91,14 @@ async function turn(textToSpeak, seconds, model, signal, onPhase) {
91
91
  onPhase?.("transcribing");
92
92
  const transcript = await transcribe(wav, model, signal);
93
93
  const transcribed = Date.now() - t1;
94
- if (!transcript)
95
- return { ok: true, text: noSpeech, notes: [...notes, timing(transcribed)] };
94
+ if (!transcript) {
95
+ // Something was loud enough to sound like speech (a cough, typing, a door), but whisper found no words in it.
96
+ // Starts with "(No speech detected" so the plugin's voice-mode hook knows the user wasn't heard.
97
+ const noWords = "(No speech detected — the microphone picked up a sound but no words, probably a cough or background noise. The microphone is now off. " +
98
+ "Ask once more out loud. If there's still no answer, stop and say on screen that voice mode is paused " +
99
+ "and they can type anything to carry on — speaking won't work until you call speak_and_listen again.)";
100
+ return { ok: true, text: noWords, notes: [...notes, timing(transcribed)] };
101
+ }
96
102
  if (heard.reason === "max-duration") {
97
103
  const l = heard.levels;
98
104
  const f = (n) => (Number.isFinite(n) ? n.toFixed(0) : "?");
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.1",
4
+ "version": "0.4.2",
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": {