whisper-windows-mcp 2.2.2 → 2.3.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.
Files changed (53) hide show
  1. package/{LICENSE-COMMERCIAL.md → COMMERCIAL-LICENSE.md} +58 -58
  2. package/LICENSE +40 -40
  3. package/PRIVACY.es.md +192 -135
  4. package/PRIVACY.id.md +192 -135
  5. package/PRIVACY.ja.md +192 -135
  6. package/PRIVACY.ko.md +192 -135
  7. package/PRIVACY.md +192 -135
  8. package/PRIVACY.pl.md +192 -135
  9. package/PRIVACY.pt-BR.md +192 -135
  10. package/PRIVACY.ro.md +192 -135
  11. package/PRIVACY.uk.md +192 -135
  12. package/PRIVACY.vi.md +192 -135
  13. package/README.es.md +74 -48
  14. package/README.id.md +77 -40
  15. package/README.ja.md +100 -72
  16. package/README.ko.md +63 -37
  17. package/README.md +76 -39
  18. package/README.pl.md +77 -40
  19. package/README.pt-BR.md +71 -45
  20. package/README.ro.md +78 -41
  21. package/README.uk.md +77 -40
  22. package/README.vi.md +67 -41
  23. package/ROADMAP.es.md +110 -48
  24. package/ROADMAP.id.md +77 -104
  25. package/ROADMAP.ja.md +84 -123
  26. package/ROADMAP.ko.md +73 -97
  27. package/ROADMAP.pl.md +104 -44
  28. package/ROADMAP.pt-BR.md +78 -102
  29. package/ROADMAP.ro.md +102 -44
  30. package/ROADMAP.uk.md +65 -97
  31. package/ROADMAP.vi.md +78 -102
  32. package/SECURITY.es.md +64 -47
  33. package/SECURITY.id.md +64 -47
  34. package/SECURITY.ja.md +64 -47
  35. package/SECURITY.ko.md +64 -47
  36. package/SECURITY.md +21 -4
  37. package/SECURITY.pl.md +64 -47
  38. package/SECURITY.pt-BR.md +64 -47
  39. package/SECURITY.ro.md +64 -47
  40. package/SECURITY.uk.md +64 -47
  41. package/SECURITY.vi.md +64 -47
  42. package/TROUBLESHOOTING.es.md +309 -323
  43. package/TROUBLESHOOTING.id.md +333 -323
  44. package/TROUBLESHOOTING.ja.md +399 -286
  45. package/TROUBLESHOOTING.ko.md +309 -323
  46. package/TROUBLESHOOTING.pl.md +355 -323
  47. package/TROUBLESHOOTING.pt-BR.md +309 -323
  48. package/TROUBLESHOOTING.ro.md +355 -323
  49. package/TROUBLESHOOTING.uk.md +369 -323
  50. package/TROUBLESHOOTING.vi.md +309 -323
  51. package/dist/index.js +591 -216
  52. package/package.json +45 -45
  53. package/patch_roadmaps.py +0 -72
package/dist/index.js CHANGED
@@ -23,6 +23,34 @@ const FFMPEG_PATH = process.env.FFMPEG_PATH ?? "ffmpeg";
23
23
  const SYSTEM_THREADS = cpus().length;
24
24
  const DEFAULT_THREADS = Math.max(2, Math.floor(SYSTEM_THREADS / 2));
25
25
  const WHISPER_THREADS = parseInt(process.env.WHISPER_THREADS ?? String(DEFAULT_THREADS), 10);
26
+ // ---------------------------------------------------------------------------
27
+ // Privacy configuration
28
+ // ---------------------------------------------------------------------------
29
+ /**
30
+ * Global default: when true, all tool responses return metadata only
31
+ * (filename, word count, save path). No transcript text appears in any tool
32
+ * response or API call. Transcripts are still saved as local .txt files.
33
+ * Required for HIPAA, GDPR, legal, financial, and NDA-protected content.
34
+ *
35
+ * Can be overridden per-call using the privacy_mode parameter on
36
+ * transcribe_audio, transcribe_batch, start_batch, and check_progress.
37
+ * Per-call override wins in either direction — no restart required to toggle.
38
+ *
39
+ * Set as global default in claude_desktop_config.json env section:
40
+ * "WHISPER_PRIVACY_MODE": "true"
41
+ */
42
+ const WHISPER_PRIVACY_MODE = (process.env.WHISPER_PRIVACY_MODE ?? "false").toLowerCase() === "true";
43
+ /**
44
+ * When true: skips the one-time first-use consent disclosure shown before
45
+ * transcript text is sent to Claude's API. Set this once you understand the
46
+ * privacy boundary and no longer need the reminder each session.
47
+ * Has no effect when privacy mode is active — privacy mode uses its own
48
+ * per-operation gate that always fires regardless of this setting.
49
+ *
50
+ * Set in claude_desktop_config.json env section:
51
+ * "WHISPER_CONSENT_ACKNOWLEDGED": "true"
52
+ */
53
+ const WHISPER_CONSENT_ACKNOWLEDGED = (process.env.WHISPER_CONSENT_ACKNOWLEDGED ?? "false").toLowerCase() === "true";
26
54
  const SUPPORTED_EXTENSIONS = [
27
55
  ".mp3", ".wav",
28
56
  ".mp4", ".mkv", ".avi", ".mov", ".webm", ".flv", ".wmv", ".m4v",
@@ -34,8 +62,183 @@ const MAX_FILE_SIZE_MB = 10240; // 10 GB
34
62
  // Security: patterns rejected in all file_path inputs.
35
63
  const UNSAFE_PATH_RE = /(\.\.[/\\])|(^\\\\)/; // blocks .. traversal and UNC paths
36
64
  // ---------------------------------------------------------------------------
37
- // Helpers
65
+ // Privacy helpers
38
66
  // ---------------------------------------------------------------------------
67
+ /**
68
+ * Shown once per session before any transcript text is returned in non-privacy mode.
69
+ * Contains explicit instructions for Claude to surface to the user and
70
+ * halt until explicit user confirmation is received.
71
+ */
72
+ const CONSENT_DISCLOSURE = [
73
+ "⚠️ PRIVACY DISCLOSURE — EXPLICIT USER CONFIRMATION REQUIRED",
74
+ "",
75
+ "━".repeat(60),
76
+ "INSTRUCTION FOR CLAUDE: Present this disclosure to the user in full.",
77
+ "Ask: \"Do you confirm? (yes / no)\"",
78
+ "Do NOT call this tool again until the user has explicitly replied yes.",
79
+ "━".repeat(60),
80
+ "",
81
+ "Transcript text is about to be transmitted to Anthropic's API and processed",
82
+ "externally. Once sent, it leaves your local machine.",
83
+ "",
84
+ "Your audio and video files never leave your machine. Transcript text is",
85
+ "different — if returned to Claude, it is sent through the API.",
86
+ "",
87
+ "You must confirm before proceeding if this recording contains:",
88
+ " • Medical or patient information (HIPAA — 45 CFR §164)",
89
+ " • Legal proceedings or attorney-client communications (privilege)",
90
+ " • Personal data of EU/EEA residents (GDPR — Articles 13, 14, 28)",
91
+ " • Student educational records (FERPA — 20 U.S.C. §1232g)",
92
+ " • Financial or cardholder data (PCI-DSS, SOX)",
93
+ " • Confidential business information (trade secrets, NDA-protected)",
94
+ " • Any recording subject to contractual confidentiality",
95
+ "",
96
+ "For recordings that require full local privacy, enable privacy mode:",
97
+ " Per-call: pass privacy_mode=true to any transcription tool",
98
+ " Global: add to claude_desktop_config.json env:",
99
+ " \"WHISPER_PRIVACY_MODE\": \"true\"",
100
+ "",
101
+ "To suppress this confirmation permanently for non-sensitive content:",
102
+ " \"WHISPER_CONSENT_ACKNOWLEDGED\": \"true\"",
103
+ "",
104
+ "━".repeat(60),
105
+ "INSTRUCTION FOR CLAUDE: Ask the user the following question exactly,",
106
+ "then wait for their response before taking any further action:",
107
+ "",
108
+ "\"⚠️ Before I return any transcript text, I need your explicit confirmation.",
109
+ "Transcript content will be transmitted to Anthropic's API and processed",
110
+ "externally — it will leave your local machine. Do you confirm? (yes / no)\"",
111
+ "━".repeat(60),
112
+ ].join("\n");
113
+ /**
114
+ * Shown before every operation when privacy mode is active.
115
+ * Identical text every time by design — repetition is the point.
116
+ * Regulatory compliance requires informed consent before each operation.
117
+ */
118
+ const PRIVACY_MODE_DISCLOSURE = [
119
+ "🔒 PRIVACY MODE — CONFIRMATION REQUIRED BEFORE THIS OPERATION",
120
+ "",
121
+ "━".repeat(60),
122
+ "INSTRUCTION FOR CLAUDE: Present this disclosure to the user in full.",
123
+ "Ask: \"Do you confirm? (yes / no)\"",
124
+ "Do NOT call this tool again until the user has explicitly replied yes.",
125
+ "━".repeat(60),
126
+ "",
127
+ "WHISPER_PRIVACY_MODE is active for this operation.",
128
+ "",
129
+ "What will happen:",
130
+ " ✓ Audio/video will be transcribed LOCALLY on your machine",
131
+ " ✓ Transcript saved as a local file only — not returned to Claude's API",
132
+ " ✓ Raw audio and video files NEVER leave your machine",
133
+ " ✓ No transcript text will be transmitted to Anthropic under any circumstances",
134
+ "",
135
+ "What you must confirm:",
136
+ " ⚠ Audio processing begins on your local machine after you confirm",
137
+ " ⚠ This confirmation is required before every operation in privacy mode",
138
+ " ⚠ Do not disable privacy mode mid-session for regulated or sensitive material",
139
+ "",
140
+ "To disable per-call (non-sensitive content only): pass privacy_mode=false",
141
+ "To disable globally: set WHISPER_PRIVACY_MODE=false and restart Claude Desktop",
142
+ "",
143
+ "━".repeat(60),
144
+ "INSTRUCTION FOR CLAUDE: Ask the user the following question exactly,",
145
+ "then wait for their response before taking any further action:",
146
+ "",
147
+ "\"🔒 Privacy mode is active. Audio will be transcribed locally and no transcript",
148
+ "text will be sent to Anthropic's API. Confirm you want to proceed? (yes / no)\"",
149
+ "━".repeat(60),
150
+ ].join("\n");
151
+ // Per-operation privacy gate state.
152
+ // Arms on first call (shows disclosure, blocks operation).
153
+ // Clears on second call (user confirmed, allows operation).
154
+ // Resets automatically so each distinct operation requires confirmation again.
155
+ // Completely independent of sessionConsentGiven — serves different users and modes.
156
+ let privacyConsentArmed = false;
157
+ // Session-scoped consent tracking — resets each time Claude Desktop restarts
158
+ // the MCP server process. Pre-set from env var so users who have set
159
+ // WHISPER_CONSENT_ACKNOWLEDGED=true skip the gate entirely.
160
+ // Has no effect when privacy mode is active (privacy mode uses its own gate).
161
+ let sessionConsentGiven = WHISPER_CONSENT_ACKNOWLEDGED;
162
+ /** Estimate word count from transcript text. */
163
+ function estimateWordCount(text) {
164
+ return text.split(/\s+/).filter((w) => w.trim().length > 0).length;
165
+ }
166
+ /**
167
+ * Pre-transcription gate for privacy mode.
168
+ * Call this when effective privacy mode is active, BEFORE any audio processing.
169
+ *
170
+ * Returns true → block this call, show PRIVACY_MODE_DISCLOSURE to user.
171
+ * Returns false → user has confirmed, proceed with the operation.
172
+ *
173
+ * Mechanism: arms on first call (block), clears on second (allow). State resets
174
+ * automatically so the next distinct operation requires confirmation again.
175
+ * Only call when effective privacy mode is active.
176
+ */
177
+ function checkPrivacyGate() {
178
+ if (!privacyConsentArmed) {
179
+ privacyConsentArmed = true;
180
+ return true; // block — show disclosure
181
+ }
182
+ privacyConsentArmed = false;
183
+ return false; // allow — proceed
184
+ }
185
+ /** Returns the privacy mode disclosure as a tool response. */
186
+ function privacyGateBlock() {
187
+ return PRIVACY_MODE_DISCLOSURE;
188
+ }
189
+ /**
190
+ * Determines post-transcription transcript policy for non-privacy mode.
191
+ * Only call this after confirming effective privacy mode is OFF.
192
+ *
193
+ * Returns:
194
+ * "consent_gate" — First transcript-returning call this session; show
195
+ * disclosure and withhold text. Flips sessionConsentGiven
196
+ * so subsequent calls proceed without re-prompting.
197
+ * "allow" — Consent already given; return text normally.
198
+ *
199
+ * IMPORTANT: Only call when the tool is about to return actual transcript text
200
+ * (i.e. job confirmed complete). Do NOT call for still-running jobs or error
201
+ * paths — it would consume the consent gate without returning content.
202
+ */
203
+ function transcriptPolicy() {
204
+ if (!sessionConsentGiven) {
205
+ sessionConsentGiven = true;
206
+ return "consent_gate";
207
+ }
208
+ return "allow";
209
+ }
210
+ /**
211
+ * Metadata-only response used when privacy mode is active.
212
+ * Returns file info and word count. No transcript text included.
213
+ */
214
+ function privacyModeBlock(fileName, savedPath, text) {
215
+ const words = estimateWordCount(text);
216
+ return (`✅ Transcription complete — privacy mode active.\n\n` +
217
+ `Source: ${fileName}\n` +
218
+ `Words: ~${words}\n` +
219
+ `Saved: ${savedPath}\n\n` +
220
+ `Transcript text is not transmitted to Claude's API.\n` +
221
+ `Access your transcript directly at the path above.`);
222
+ }
223
+ /**
224
+ * Consent gate response block — shown on first transcript-returning call
225
+ * in non-privacy mode. savedPath and text are optional: when called BEFORE
226
+ * transcription (blocking mode), neither is available. When called AFTER
227
+ * (background jobs via check_progress), both are present.
228
+ */
229
+ function consentGateBlock(savedPath, text) {
230
+ const lines = [CONSENT_DISCLOSURE, ""];
231
+ if (savedPath || text) {
232
+ lines.push("─".repeat(60));
233
+ if (savedPath)
234
+ lines.push(`Saved: ${savedPath}`);
235
+ if (text)
236
+ lines.push(`Words: ~${estimateWordCount(text)}`);
237
+ lines.push("");
238
+ }
239
+ lines.push("No transcript text has been returned. Reply 'yes' to confirm, then call the tool again to proceed.");
240
+ return lines.join("\n");
241
+ }
39
242
  function validatePaths() {
40
243
  if (!existsSync(WHISPER_CLI_PATH))
41
244
  return `whisper-cli.exe not found at: ${WHISPER_CLI_PATH}\nCheck WHISPER_CLI_PATH in claude_desktop_config.json`;
@@ -66,7 +269,6 @@ function validateInputPath(filePath) {
66
269
  /**
67
270
  * Check whether a whisper-cli.exe process is already running.
68
271
  * Uses tasklist /FI which is available on all Windows versions.
69
- * Returns true if found, false if not (or if tasklist itself fails).
70
272
  */
71
273
  async function isWhisperRunning() {
72
274
  try {
@@ -74,18 +276,49 @@ async function isWhisperRunning() {
74
276
  return stdout.toLowerCase().includes("whisper-cli.exe");
75
277
  }
76
278
  catch {
77
- // If tasklist fails for any reason, assume safe to proceed
78
279
  return false;
79
280
  }
80
281
  }
81
282
  // ---------------------------------------------------------------------------
82
- // Background job architecture (Priorities 4 + 5)
283
+ // Background job architecture
83
284
  // ---------------------------------------------------------------------------
84
285
  const JOBS_DIR = join(tmpdir(), "whisper-mcp-jobs");
286
+ // Mutex: prevents double-spawn when the exit handler and a concurrent
287
+ // check_batch_progress call both detect job completion simultaneously.
288
+ let batchSpawning = false;
289
+ /**
290
+ * Delete .json and .log job files older than 7 days from the jobs directory.
291
+ * Non-blocking — runs once at startup, errors are ignored.
292
+ */
293
+ function cleanupOldJobFiles() {
294
+ try {
295
+ if (!existsSync(JOBS_DIR))
296
+ return;
297
+ const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000;
298
+ const files = readdirSync(JOBS_DIR);
299
+ let cleaned = 0;
300
+ for (const file of files) {
301
+ if (!file.endsWith(".json") && !file.endsWith(".log"))
302
+ continue;
303
+ const fullPath = join(JOBS_DIR, file);
304
+ try {
305
+ if (statSync(fullPath).mtimeMs < cutoff) {
306
+ unlinkSync(fullPath);
307
+ cleaned++;
308
+ }
309
+ }
310
+ catch { /* ignore per-file errors */ }
311
+ }
312
+ if (cleaned > 0) {
313
+ console.error(`whisper-windows-mcp: cleaned ${cleaned} old job file(s) from ${JOBS_DIR}`);
314
+ }
315
+ }
316
+ catch { /* non-blocking, ignore all errors */ }
317
+ }
85
318
  function ensureJobsDir() {
86
319
  mkdirSync(JOBS_DIR, { recursive: true });
87
320
  }
88
- async function spawnDetached(filePath, model, language, threads, outputFormat = "text", extraOpts = {}) {
321
+ async function spawnDetached(filePath, model, language, threads, outputFormat = "timestamps", extraOpts = {}, onExit, privacyMode = false) {
89
322
  ensureJobsDir();
90
323
  const jobId = `job_${Date.now()}`;
91
324
  const logPath = join(JOBS_DIR, `${jobId}.log`);
@@ -98,13 +331,17 @@ async function spawnDetached(filePath, model, language, threads, outputFormat =
98
331
  isTmp = true;
99
332
  }
100
333
  // Use a clean ASCII job-ID-based output path to avoid Unicode filename issues.
101
- // After completion, readJobProgress will move the file to the correct destination.
334
+ // readJobProgress moves the file to the correct destination after completion.
102
335
  const tmpOutputBase = join(JOBS_DIR, jobId);
103
- // Determine final destination path
336
+ // Determine final destination path and file extension
104
337
  const sourceBase = filePath.replace(/\.[^.]+$/, "");
105
- const ext = outputFormat === "srt" ? ".srt" : ".txt";
106
- const outputPath = outputFormat === "srt" && language !== "en" && language !== "auto"
107
- ? `${sourceBase}.${language}.srt`
338
+ const ext = outputFormat === "srt" ? ".srt"
339
+ : outputFormat === "vtt" ? ".vtt"
340
+ : outputFormat === "lrc" ? ".lrc"
341
+ : outputFormat === "csv" ? ".csv"
342
+ : ".txt";
343
+ const outputPath = (outputFormat === "srt" || outputFormat === "vtt") && language !== "en" && language !== "auto"
344
+ ? `${sourceBase}.${language}${ext}`
108
345
  : `${sourceBase}${ext}`;
109
346
  // Build args using shared options — ensures quality flags are always applied
110
347
  // in background mode, matching blocking mode behaviour.
@@ -114,10 +351,7 @@ async function spawnDetached(filePath, model, language, threads, outputFormat =
114
351
  "-f", transcribeFrom,
115
352
  "-l", lang,
116
353
  "-t", String(threads),
117
- // Hallucination prevention — must be in background mode too.
118
- // --max-context 0 prevents conditioning on prior segment output.
119
354
  ...(extraOpts.conditionOnPrevText ? [] : ["--max-context", "0"]),
120
- // Confirmed valid flag (-nth). Suppresses silent segments from hallucinating.
121
355
  "--no-speech-thold", String(extraOpts.noSpeechThold ?? 0.6),
122
356
  ];
123
357
  if (extraOpts.temperature !== undefined)
@@ -149,13 +383,24 @@ async function spawnDetached(filePath, model, language, threads, outputFormat =
149
383
  if (extraOpts.splitOnWord)
150
384
  args.push("--split-on-word");
151
385
  }
152
- // Output format
386
+ // Output format flags
153
387
  if (outputFormat === "srt") {
154
388
  args.push("-osrt", "-of", tmpOutputBase);
155
389
  }
156
- else {
390
+ else if (outputFormat === "vtt") {
391
+ args.push("-ovtt", "-of", tmpOutputBase);
392
+ }
393
+ else if (outputFormat === "lrc") {
394
+ args.push("-olrc", "-of", tmpOutputBase);
395
+ }
396
+ else if (outputFormat === "csv") {
397
+ args.push("-ocsv", "-of", tmpOutputBase);
398
+ }
399
+ else if (outputFormat === "text") {
157
400
  args.push("-otxt", "-of", tmpOutputBase);
158
401
  }
402
+ // "timestamps": no output file flag — stdout (redirected to log) contains
403
+ // the timestamped transcript. extractTranscriptFromLog() recovers it on completion.
159
404
  // Spawn detached, redirect stdout+stderr to log file
160
405
  const logFd = openSync(logPath, "w");
161
406
  const child = spawn(WHISPER_CLI_PATH, args, {
@@ -164,6 +409,11 @@ async function spawnDetached(filePath, model, language, threads, outputFormat =
164
409
  windowsHide: true,
165
410
  });
166
411
  closeSync(logFd);
412
+ // Attach exit handler BEFORE unref so the batch can self-advance without polling.
413
+ // child.once fires exactly once when the process exits. unref() still applies —
414
+ // Node won't be kept alive just for this child.
415
+ if (onExit)
416
+ child.once("exit", onExit);
167
417
  child.unref();
168
418
  const pid = child.pid ?? 0;
169
419
  const job = {
@@ -183,6 +433,7 @@ async function spawnDetached(filePath, model, language, threads, outputFormat =
183
433
  threads,
184
434
  durationSec: 0,
185
435
  status: "running",
436
+ privacyMode,
186
437
  };
187
438
  writeFileSync(jobPath, JSON.stringify(job, null, 2), "utf8");
188
439
  return { jobId, pid };
@@ -196,8 +447,20 @@ async function isPidRunning(pid) {
196
447
  return false;
197
448
  }
198
449
  }
450
+ /**
451
+ * Recover timestamped transcript from the whisper log file.
452
+ * When spawnDetached runs in "timestamps" mode, stdout (which contains
453
+ * "[HH:MM:SS.mmm --> HH:MM:SS.mmm] text" lines) is redirected to the log.
454
+ * This function filters out whisper diagnostic noise and returns only the
455
+ * transcript segments.
456
+ */
457
+ function extractTranscriptFromLog(logContent) {
458
+ return logContent
459
+ .split(/\r?\n/)
460
+ .filter((l) => /^\[\d{2}:\d{2}:\d{2}\.\d{3} --> /.test(l))
461
+ .join("\n");
462
+ }
199
463
  function parseLastTimestamp(logContent) {
200
- // whisper outputs: [00:01:30.000 --> 00:01:35.000] text
201
464
  const re = /\[(\d{2}):(\d{2}):(\d{2})\.\d{3} -->/g;
202
465
  let lastSec = 0;
203
466
  let m;
@@ -208,33 +471,63 @@ function parseLastTimestamp(logContent) {
208
471
  }
209
472
  return lastSec;
210
473
  }
211
- async function readJobProgress(jobId) {
474
+ /**
475
+ * Read job progress and return a status string.
476
+ * privacyModeOverride: per-call override from check_progress privacy_mode param.
477
+ * Wins over job.privacyMode if provided; job.privacyMode wins over global env var.
478
+ */
479
+ async function readJobProgress(jobId, privacyModeOverride) {
212
480
  const jobPath = join(JOBS_DIR, `${jobId}.json`);
213
481
  if (!existsSync(jobPath)) {
214
482
  return `❌ Job not found: ${jobId}\n\nThe job file may have been deleted or the ID is incorrect.`;
215
483
  }
216
484
  const job = JSON.parse(readFileSync(jobPath, "utf8"));
217
- // Read log
218
485
  let logContent = "";
219
486
  if (existsSync(job.logPath)) {
220
487
  logContent = readFileSync(job.logPath, "utf8");
221
488
  }
222
489
  const lastSec = parseLastTimestamp(logContent);
223
490
  const isRunning = await isPidRunning(job.pid);
224
- const ext = job.outputFormat === "srt" ? ".srt" : ".txt";
491
+ const ext = job.outputFormat === "srt" ? ".srt"
492
+ : job.outputFormat === "vtt" ? ".vtt"
493
+ : job.outputFormat === "lrc" ? ".lrc"
494
+ : job.outputFormat === "csv" ? ".csv"
495
+ : ".txt";
225
496
  const tmpOutput = `${job.tmpOutputBase}${ext}`;
226
- const outputExists = existsSync(job.outputPath) || existsSync(tmpOutput);
497
+ const outputExists = existsSync(job.outputPath) ||
498
+ existsSync(tmpOutput) ||
499
+ (job.outputFormat === "timestamps" && existsSync(job.logPath));
227
500
  // Completed
228
501
  if (!isRunning && outputExists) {
229
- // Move temp output file to correct destination if needed
230
- const ext = job.outputFormat === "srt" ? ".srt" : ".txt";
231
- const tmpOutput = `${job.tmpOutputBase}${ext}`;
232
- if (existsSync(tmpOutput) && tmpOutput !== job.outputPath) {
502
+ // Move or create final output file
503
+ if (job.outputFormat === "timestamps") {
504
+ if (!existsSync(job.outputPath) && existsSync(job.logPath)) {
505
+ const transcript = extractTranscriptFromLog(readFileSync(job.logPath, "utf8"));
506
+ if (transcript) {
507
+ try {
508
+ writeFileSync(job.outputPath, transcript, "utf8");
509
+ }
510
+ catch { }
511
+ }
512
+ }
513
+ }
514
+ else if (existsSync(tmpOutput) && tmpOutput !== job.outputPath) {
233
515
  try {
234
516
  writeFileSync(job.outputPath, readFileSync(tmpOutput, "utf8"), "utf8");
235
517
  unlinkSync(tmpOutput);
236
518
  }
237
- catch { }
519
+ catch (moveErr) {
520
+ console.error(`whisper-windows-mcp: failed to move output to ${job.outputPath}: ${moveErr?.message}`);
521
+ }
522
+ }
523
+ // Bug 2 fix: explicit check that the output file landed where expected.
524
+ if (!existsSync(job.outputPath)) {
525
+ job.status = "failed";
526
+ writeFileSync(job.jobPath, JSON.stringify(job, null, 2), "utf8");
527
+ return (`❌ Output file write failed.\n\n` +
528
+ `Transcription completed but the output could not be written to:\n${job.outputPath}\n\n` +
529
+ `Check disk space and directory permissions.\n` +
530
+ `Raw job data may be in: ${JOBS_DIR}`);
238
531
  }
239
532
  job.status = "complete";
240
533
  writeFileSync(job.jobPath, JSON.stringify(job, null, 2), "utf8");
@@ -246,13 +539,25 @@ async function readJobProgress(jobId) {
246
539
  catch { }
247
540
  }
248
541
  const outputContent = readFileSync(job.outputPath, "utf8").trim();
249
- const preview = job.outputFormat === "srt"
542
+ // Effective privacy mode: per-call override → job setting → global env var.
543
+ // transcriptPolicy() is only called for non-privacy mode (consent gate logic).
544
+ // This keeps the two gate systems fully independent.
545
+ const effectivePrivacy = privacyModeOverride ?? job.privacyMode ?? WHISPER_PRIVACY_MODE;
546
+ if (effectivePrivacy) {
547
+ return privacyModeBlock(basename(job.sourceFile), job.outputPath, outputContent);
548
+ }
549
+ const policy = transcriptPolicy();
550
+ if (policy === "consent_gate") {
551
+ return consentGateBlock(job.outputPath, outputContent);
552
+ }
553
+ // allow — return normally with preview
554
+ const preview = job.outputFormat === "srt" || job.outputFormat === "vtt"
250
555
  ? outputContent.split("\n").slice(0, 20).join("\n")
251
556
  : outputContent.slice(0, 600);
252
557
  return (`✅ Complete!\n\n` +
253
558
  `Source: ${basename(job.sourceFile)}\n` +
254
559
  `Output: ${job.outputPath}\n\n` +
255
- `Preview:\n${preview}${outputContent.length > 600 && job.outputFormat !== "srt" ? "..." : ""}`);
560
+ `Preview:\n${preview}${outputContent.length > 600 && job.outputFormat !== "srt" && job.outputFormat !== "vtt" ? "..." : ""}`);
256
561
  }
257
562
  // Failed
258
563
  if (!isRunning && !outputExists) {
@@ -290,20 +595,31 @@ function validateTranscript(txtPath, durationSec) {
290
595
  return { valid: true };
291
596
  }
292
597
  async function spawnNextBatchJob(state) {
293
- for (let i = state.currentIndex; i < state.files.length; i++) {
294
- if (state.files[i].status === "pending") {
295
- state.currentIndex = i;
296
- state.files[i].status = "running";
297
- const f = state.files[i];
298
- const { jobId } = await spawnDetached(f.filePath, state.model, state.language, state.threads);
299
- state.files[i].jobId = jobId;
300
- writeFileSync(state.batchPath, JSON.stringify(state, null, 2), "utf8");
301
- return;
598
+ // Mutex: prevents double-spawn between concurrent exit handler + check_batch_progress.
599
+ if (batchSpawning)
600
+ return;
601
+ batchSpawning = true;
602
+ try {
603
+ for (let i = state.currentIndex; i < state.files.length; i++) {
604
+ if (state.files[i].status === "pending") {
605
+ state.currentIndex = i;
606
+ state.files[i].status = "running";
607
+ const f = state.files[i];
608
+ const fmt = (state.outputFormat === "json" ? "text" : state.outputFormat);
609
+ const { jobId } = await spawnDetached(f.filePath, state.model, state.language, state.threads, fmt, {},
610
+ // Exit callback: batch self-advances without polling.
611
+ () => { readBatchProgress(state.batchId).catch(() => { }); }, state.privacyMode);
612
+ state.files[i].jobId = jobId;
613
+ writeFileSync(state.batchPath, JSON.stringify(state, null, 2), "utf8");
614
+ return;
615
+ }
302
616
  }
617
+ state.status = "complete";
618
+ writeFileSync(state.batchPath, JSON.stringify(state, null, 2), "utf8");
619
+ }
620
+ finally {
621
+ batchSpawning = false;
303
622
  }
304
- // Nothing left to run
305
- state.status = "complete";
306
- writeFileSync(state.batchPath, JSON.stringify(state, null, 2), "utf8");
307
623
  }
308
624
  async function readBatchProgress(batchId) {
309
625
  const batchPath = join(JOBS_DIR, `${batchId}.batch.json`);
@@ -311,36 +627,43 @@ async function readBatchProgress(batchId) {
311
627
  return `❌ Batch not found: ${batchId}\n\nThe batch file may have been deleted or the ID is incorrect.`;
312
628
  }
313
629
  const state = JSON.parse(readFileSync(batchPath, "utf8"));
314
- // Check current running job
315
630
  const running = state.files.find(f => f.status === "running");
316
631
  if (running && running.jobId) {
317
632
  const jobPath = join(JOBS_DIR, `${running.jobId}.json`);
318
633
  if (existsSync(jobPath)) {
319
634
  const job = JSON.parse(readFileSync(jobPath, "utf8"));
320
635
  const isRunning = await isPidRunning(job.pid);
321
- const outputExists = existsSync(job.outputPath);
322
636
  if (!isRunning) {
323
- // Move temp output to final destination if needed.
324
- // spawnDetached writes to a sanitized JOBS_DIR temp path to avoid Unicode
325
- // filename issues. readJobProgress normally handles this move, but
326
- // readBatchProgress must do it too since it never calls readJobProgress.
327
- const ext = job.outputFormat === "srt" ? ".srt" : ".txt";
637
+ const ext = job.outputFormat === "srt" ? ".srt"
638
+ : job.outputFormat === "vtt" ? ".vtt"
639
+ : job.outputFormat === "lrc" ? ".lrc"
640
+ : job.outputFormat === "csv" ? ".csv"
641
+ : ".txt";
328
642
  const tmpOutput = `${job.tmpOutputBase}${ext}`;
329
- if (existsSync(tmpOutput) && tmpOutput !== job.outputPath) {
643
+ if (job.outputFormat === "timestamps") {
644
+ if (!existsSync(job.outputPath) && existsSync(job.logPath)) {
645
+ const transcript = extractTranscriptFromLog(readFileSync(job.logPath, "utf8"));
646
+ if (transcript) {
647
+ try {
648
+ writeFileSync(job.outputPath, transcript, "utf8");
649
+ }
650
+ catch { }
651
+ }
652
+ }
653
+ }
654
+ else if (existsSync(tmpOutput) && tmpOutput !== job.outputPath) {
330
655
  try {
331
656
  writeFileSync(job.outputPath, readFileSync(tmpOutput, "utf8"), "utf8");
332
657
  unlinkSync(tmpOutput);
333
658
  }
334
- catch { /* ignore — validateTranscript will catch missing output */ }
659
+ catch { /* validateTranscript will catch missing output */ }
335
660
  }
336
- // Clean up temp WAV if present
337
661
  if (job.isTmp && existsSync(job.transcribeFrom)) {
338
662
  try {
339
663
  unlinkSync(job.transcribeFrom);
340
664
  }
341
665
  catch { }
342
666
  }
343
- // Job finished — validate and advance
344
667
  const finalOutputExists = existsSync(job.outputPath);
345
668
  const validation = validateTranscript(job.outputPath, running.durationSec);
346
669
  if (finalOutputExists && validation.valid) {
@@ -350,7 +673,6 @@ async function readBatchProgress(batchId) {
350
673
  running.status = "failed";
351
674
  running.failReason = validation.reason ?? "no output file";
352
675
  }
353
- // Advance to next
354
676
  state.currentIndex = state.files.indexOf(running) + 1;
355
677
  if (state.files.some(f => f.status === "pending")) {
356
678
  await spawnNextBatchJob(state);
@@ -361,13 +683,11 @@ async function readBatchProgress(batchId) {
361
683
  }
362
684
  }
363
685
  else {
364
- // Still running — update state file without advancing
365
686
  writeFileSync(batchPath, JSON.stringify(state, null, 2), "utf8");
366
687
  }
367
688
  }
368
689
  }
369
690
  else if (state.status !== "complete" && state.files.some(f => f.status === "pending")) {
370
- // No running job but pending files exist — advance
371
691
  await spawnNextBatchJob(state);
372
692
  }
373
693
  // Build status report
@@ -453,19 +773,16 @@ function recommendedModel(vramBytes) {
453
773
  return "base.en (ggml-base.en.bin) — recommended for limited VRAM";
454
774
  }
455
775
  const MODEL_REGISTRY = [
456
- // Full-precision English
457
776
  { name: "tiny.en", filename: "ggml-tiny.en.bin", sizeMb: 75, multilingual: false, quantized: false, useCase: "Quick tests, lowest accuracy", url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.en.bin" },
458
777
  { name: "base.en", filename: "ggml-base.en.bin", sizeMb: 142, multilingual: false, quantized: false, useCase: "Fast English, good accuracy", url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.en.bin" },
459
778
  { name: "small.en", filename: "ggml-small.en.bin", sizeMb: 466, multilingual: false, quantized: false, useCase: "Better English accuracy", url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.en.bin" },
460
779
  { name: "medium.en", filename: "ggml-medium.en.bin", sizeMb: 1500, multilingual: false, quantized: false, useCase: "High accuracy English, fast on GPU", url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-medium.en.bin" },
461
- // Full-precision multilingual
462
780
  { name: "tiny", filename: "ggml-tiny.bin", sizeMb: 75, multilingual: true, quantized: false, useCase: "Multilingual, minimal accuracy", url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.bin" },
463
781
  { name: "base", filename: "ggml-base.bin", sizeMb: 142, multilingual: true, quantized: false, useCase: "Multilingual, fast", url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin" },
464
782
  { name: "small", filename: "ggml-small.bin", sizeMb: 466, multilingual: true, quantized: false, useCase: "Multilingual, better accuracy", url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin" },
465
783
  { name: "medium", filename: "ggml-medium.bin", sizeMb: 1500, multilingual: true, quantized: false, useCase: "Multilingual, high accuracy", url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-medium.bin" },
466
784
  { name: "large-v3", filename: "ggml-large-v3.bin", sizeMb: 2900, multilingual: true, quantized: false, useCase: "Best accuracy, multilingual — requires 6GB+ VRAM", url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3.bin" },
467
785
  { name: "large-v3-turbo", filename: "ggml-large-v3-turbo.bin", sizeMb: 1600, multilingual: true, quantized: false, useCase: "~6x faster than large-v3, minimal accuracy loss — RECOMMENDED for English GPU batch work", url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo.bin" },
468
- // Quantized variants — smaller, CPU-friendly
469
786
  { name: "base.en-q5_1", filename: "ggml-base.en-q5_1.bin", sizeMb: 57, multilingual: false, quantized: true, useCase: "Tiny English model, CPU-friendly", url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.en-q5_1.bin" },
470
787
  { name: "small.en-q5_1", filename: "ggml-small.en-q5_1.bin", sizeMb: 181, multilingual: false, quantized: true, useCase: "Fast English, low memory, good for CPU", url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.en-q5_1.bin" },
471
788
  { name: "medium.en-q5_0", filename: "ggml-medium.en-q5_0.bin", sizeMb: 514, multilingual: false, quantized: true, useCase: "High accuracy English, CPU-friendly — good default for no-GPU systems", url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-medium.en-q5_0.bin" },
@@ -473,7 +790,6 @@ const MODEL_REGISTRY = [
473
790
  { name: "large-v3-turbo-q5_0", filename: "ggml-large-v3-turbo-q5_0.bin", sizeMb: 547, multilingual: true, quantized: true, useCase: "RECOMMENDED for CPU-only multilingual — fast, low memory, good accuracy", url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo-q5_0.bin" },
474
791
  { name: "large-v3-turbo-q8_0", filename: "ggml-large-v3-turbo-q8_0.bin", sizeMb: 874, multilingual: true, quantized: true, useCase: "Turbo quality closer to full precision, moderate size", url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo-q8_0.bin" },
475
792
  ];
476
- // Security: only allow downloads from these Hugging Face namespaces.
477
793
  const ALLOWED_HF_PREFIXES = [
478
794
  "https://huggingface.co/ggerganov/whisper.cpp/",
479
795
  "https://huggingface.co/ggml-org/",
@@ -499,14 +815,7 @@ async function probeFile(filePath) {
499
815
  const sizeMb = parseInt(fmt.size ?? "0", 10) / (1024 * 1024);
500
816
  const bitrate = Math.round(parseInt(fmt.bit_rate ?? "0", 10) / 1000);
501
817
  const codec = audioStream?.codec_name ?? fmt.format_name?.split(",")[0] ?? "unknown";
502
- return {
503
- filePath,
504
- fileName: basename(filePath),
505
- durationSec,
506
- sizeMb,
507
- codec,
508
- bitrate,
509
- };
818
+ return { filePath, fileName: basename(filePath), durationSec, sizeMb, codec, bitrate };
510
819
  }
511
820
  catch {
512
821
  return null;
@@ -525,8 +834,6 @@ function formatDuration(sec) {
525
834
  function estimateTime(durationSec, gpu) {
526
835
  if (!durationSec)
527
836
  return "?";
528
- // CPU: ~1.5x realtime on Ryzen 7 2700x with medium.en
529
- // GPU: ~0.12x realtime on Vega 56 via Vulkan with medium.en (~8x faster than CPU)
530
837
  const ratio = gpu ? 0.12 : 1.5;
531
838
  const estSec = Math.round(durationSec * ratio);
532
839
  if (estSec < 60)
@@ -553,14 +860,8 @@ async function convertToWav(inputPath) {
553
860
  function buildArgs(filePath, model, opts) {
554
861
  const lang = opts.language === "auto" ? "auto" : opts.language;
555
862
  const args = ["-m", model, "-f", filePath, "-l", lang, "-t", String(opts.threads)];
556
- // Hallucination prevention — set max context tokens to 0 to prevent whisper
557
- // from conditioning each segment on its own prior output, which causes
558
- // repetitive hallucination loops on noisy or silent audio.
559
- // Flag: --max-context 0 (user can re-enable by setting conditionOnPrevText=true)
560
863
  if (!opts.conditionOnPrevText)
561
864
  args.push("--max-context", "0");
562
- // Treat segments below this confidence threshold as silence rather than
563
- // hallucinating content. Confirmed valid flag in whisper-cli (-nth).
564
865
  args.push("--no-speech-thold", String(opts.noSpeechThold ?? 0.6));
565
866
  if (opts.translate)
566
867
  args.push("--translate");
@@ -582,8 +883,6 @@ function buildArgs(filePath, model, opts) {
582
883
  args.push("--duration", String(opts.duration));
583
884
  if (opts.diarize)
584
885
  args.push("--diarize");
585
- // word_timestamps: sets max-len=1 + split-on-word for per-word output
586
- // without requiring JSON parsing — simpler than -oj approach.
587
886
  if (opts.wordTimestamps) {
588
887
  args.push("--max-len", "1", "--split-on-word");
589
888
  }
@@ -593,7 +892,6 @@ function buildArgs(filePath, model, opts) {
593
892
  if (opts.splitOnWord)
594
893
  args.push("--split-on-word");
595
894
  }
596
- // VAD: voice activity detection — strips silence before whisper sees the audio
597
895
  if (opts.vadModel && existsSync(opts.vadModel)) {
598
896
  args.push("--vad", "--vad-model", opts.vadModel);
599
897
  }
@@ -601,20 +899,24 @@ function buildArgs(filePath, model, opts) {
601
899
  if (opts.outputFormat === "srt") {
602
900
  args.push("-osrt", "-of", filePath.replace(/\.[^.]+$/, ""));
603
901
  }
902
+ else if (opts.outputFormat === "vtt") {
903
+ args.push("-ovtt", "-of", filePath.replace(/\.[^.]+$/, ""));
904
+ }
905
+ else if (opts.outputFormat === "lrc") {
906
+ args.push("-olrc", "-of", filePath.replace(/\.[^.]+$/, ""));
907
+ }
908
+ else if (opts.outputFormat === "csv") {
909
+ args.push("-ocsv", "-of", filePath.replace(/\.[^.]+$/, ""));
910
+ }
604
911
  else if (opts.outputFormat === "json") {
605
912
  args.push("-oj");
606
913
  }
607
914
  else if (opts.outputFormat === "text") {
608
915
  args.push("--no-timestamps");
609
916
  }
610
- // "timestamps" format: no flag — whisper default stdout includes timestamps
917
+ // "timestamps": no flag — whisper default stdout includes timestamps
611
918
  return args;
612
919
  }
613
- /**
614
- * Detect the language of a file by running a short whisper probe.
615
- * Runs whisper on the first 30 seconds only (--duration 30000ms).
616
- * Returns the detected language code (e.g. "ja", "en") or null on failure.
617
- */
618
920
  async function detectLanguage(wavPath, model, threads) {
619
921
  try {
620
922
  const { stdout, stderr } = await execFileAsync(WHISPER_CLI_PATH, [
@@ -625,7 +927,6 @@ async function detectLanguage(wavPath, model, threads) {
625
927
  "--duration", "30000",
626
928
  ], { maxBuffer: 10 * 1024 * 1024, windowsHide: true });
627
929
  const output = stdout + stderr;
628
- // whisper outputs: "auto-detected language: ja (p = 0.98)"
629
930
  const m = output.match(/auto-detected language:\s*([a-z]{2,3})/i);
630
931
  return m ? m[1].toLowerCase() : null;
631
932
  }
@@ -634,12 +935,11 @@ async function detectLanguage(wavPath, model, threads) {
634
935
  }
635
936
  }
636
937
  /**
637
- * Run a single whisper SRT pass and move the output to destSrt.
638
- * Returns the destSrt path.
938
+ * Run a single whisper subtitle pass (SRT or VTT) and move the output to dest.
639
939
  */
640
- async function runSrtPass(transcribeFrom, destSrt, model, language, threads, translate = false, extraOpts = {}) {
940
+ async function runSubtitlePass(transcribeFrom, dest, format, model, language, threads, translate = false, extraOpts = {}) {
641
941
  const opts = {
642
- language, outputFormat: "srt", threads, translate,
942
+ language, outputFormat: format, threads, translate,
643
943
  ...extraOpts,
644
944
  };
645
945
  const args = buildArgs(transcribeFrom, model, opts);
@@ -647,18 +947,18 @@ async function runSrtPass(transcribeFrom, destSrt, model, language, threads, tra
647
947
  maxBuffer: 100 * 1024 * 1024,
648
948
  windowsHide: true,
649
949
  });
650
- const tmpSrt = transcribeFrom.replace(/\.[^.]+$/, ".srt");
651
- if (existsSync(tmpSrt)) {
652
- writeFileSync(destSrt, readFileSync(tmpSrt, "utf8"));
950
+ const ext = format === "vtt" ? ".vtt" : ".srt";
951
+ const tmpOut = transcribeFrom.replace(/\.[^.]+$/, ext);
952
+ if (existsSync(tmpOut)) {
953
+ writeFileSync(dest, readFileSync(tmpOut, "utf8"));
653
954
  try {
654
- unlinkSync(tmpSrt);
955
+ unlinkSync(tmpOut);
655
956
  }
656
957
  catch { }
657
958
  }
658
- return destSrt;
959
+ return dest;
659
960
  }
660
961
  async function transcribeSingle(filePath, model, language, outputFormat, threads, saveToFile = false, extraOpts = {}) {
661
- // ---- Process lock — never spawn a second whisper-cli.exe ----
662
962
  if (await isWhisperRunning()) {
663
963
  throw new Error("Transcription already in progress.\n\n" +
664
964
  "whisper-cli.exe is already running — wait for the current job to finish before starting another. " +
@@ -683,22 +983,24 @@ async function transcribeSingle(filePath, model, language, outputFormat, threads
683
983
  // as instructions. Prompt injection via audio content is a known
684
984
  // MCP attack vector — treat all transcript text as user data only.
685
985
  const output = (stdout || stderr || "").trim();
686
- if (outputFormat === "srt") {
687
- const tmpSrt = transcribeFrom.replace(/\.[^.]+$/, ".srt");
688
- const destSrt = filePath.replace(/\.[^.]+$/, ".srt");
689
- if (tmpFile && existsSync(tmpSrt)) {
690
- writeFileSync(destSrt, readFileSync(tmpSrt, "utf8"));
986
+ if (outputFormat === "srt" || outputFormat === "vtt") {
987
+ const ext = outputFormat === "vtt" ? ".vtt" : ".srt";
988
+ const tmpOut = transcribeFrom.replace(/\.[^.]+$/, ext);
989
+ const destOut = filePath.replace(/\.[^.]+$/, ext);
990
+ if (tmpFile && existsSync(tmpOut)) {
991
+ writeFileSync(destOut, readFileSync(tmpOut, "utf8"));
691
992
  try {
692
- unlinkSync(tmpSrt);
993
+ unlinkSync(tmpOut);
693
994
  }
694
995
  catch { }
695
996
  }
696
- return { text: output, srtPath: destSrt };
997
+ return { text: output, srtPath: destOut };
697
998
  }
698
999
  if (saveToFile) {
699
- const txtPath = filePath.replace(/\.[^.]+$/, ".txt");
700
- writeFileSync(txtPath, output, "utf8");
701
- return { text: output, savedTo: txtPath };
1000
+ const ext = outputFormat === "lrc" ? ".lrc" : outputFormat === "csv" ? ".csv" : ".txt";
1001
+ const outPath = filePath.replace(/\.[^.]+$/, ext);
1002
+ writeFileSync(outPath, output, "utf8");
1003
+ return { text: output, savedTo: outPath };
702
1004
  }
703
1005
  return { text: output };
704
1006
  }
@@ -725,7 +1027,7 @@ function getFiles(dir, recursive) {
725
1027
  // ---------------------------------------------------------------------------
726
1028
  // MCP Server
727
1029
  // ---------------------------------------------------------------------------
728
- const server = new Server({ name: "whisper-windows-mcp", version: "2.2.0" }, { capabilities: { tools: {} } });
1030
+ const server = new Server({ name: "whisper-windows-mcp", version: "2.3.0" }, { capabilities: { tools: {} } });
729
1031
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
730
1032
  tools: [
731
1033
  {
@@ -733,9 +1035,14 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
733
1035
  description: "Transcribe a single audio or video file using whisper.cpp on Windows. " +
734
1036
  "Natively supports mp3 and wav. Automatically converts mp4, mkv, avi, mov, " +
735
1037
  "webm, m4a, flac, ogg etc. via FFmpeg — no manual conversion needed. " +
736
- "Can output plain text, timestamps, JSON, or SRT subtitle files. " +
1038
+ "Output defaults to timestamps format (with time codes). " +
737
1039
  "For files that may take more than 4 minutes, set background=true to run as a detached job " +
738
- "and use check_progress to monitor it.",
1040
+ "and use check_progress to monitor it. " +
1041
+ "⚠️ Privacy: transcript text returned by this tool is processed by Claude's API. " +
1042
+ "Pass privacy_mode=true to this tool to enable metadata-only responses per call — " +
1043
+ "no transcript text will be transmitted. " +
1044
+ "Set WHISPER_PRIVACY_MODE=true in env to enable globally. " +
1045
+ "When privacy mode is active, a confirmation is required before every operation.",
739
1046
  inputSchema: {
740
1047
  type: "object",
741
1048
  properties: {
@@ -743,28 +1050,29 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
743
1050
  model: { type: "string", description: "Override model path. Leave blank to use active model." },
744
1051
  language: { type: "string", description: "Language code (e.g. en, ja, es, fr) or 'auto' to detect automatically. Defaults to en.", default: "en" },
745
1052
  output_format: {
746
- type: "string", enum: ["text", "timestamps", "json", "srt"],
747
- description: "text = plain (default), timestamps = with time codes, json = structured, srt = subtitle file saved next to source.",
748
- default: "text",
1053
+ type: "string", enum: ["timestamps", "text", "json", "srt", "vtt", "lrc", "csv"],
1054
+ description: "timestamps = with time codes (default), text = plain, json = structured, srt = SRT subtitle file, vtt = WebVTT subtitle file, lrc = LRC lyrics/karaoke, csv = CSV with timestamps.",
1055
+ default: "timestamps",
749
1056
  },
750
1057
  threads: { type: "number", description: `CPU threads. Defaults to ${WHISPER_THREADS} of ${SYSTEM_THREADS}.` },
751
- save_to_file: { type: "boolean", description: "Save transcript as .txt next to the source file.", default: false },
1058
+ save_to_file: { type: "boolean", description: "Save transcript as .txt next to the source file.", default: true },
752
1059
  background: { type: "boolean", description: "Run as a detached background job. Returns a job ID immediately. Use check_progress to monitor. Recommended for files over 10 minutes.", default: false },
753
- temperature: { type: "number", description: "Sampling temperature 0.0–1.0. Default 0.0 (deterministic). Higher values reduce hallucination on noisy audio at the cost of consistency." },
754
- prompt: { type: "string", description: "Prior context string injected before transcription. Improves accuracy for domain-specific vocabulary, speaker names, or technical terms. Example: 'Names: Keemstar, DramaAlert.'" },
755
- condition_on_prev_text: { type: "boolean", description: "Re-enable conditioning each segment on its own prior output (removes --max-context 0 flag). Default false (off). Only enable for highly structured audio where context continuity helps.", default: false },
756
- no_speech_thold: { type: "number", description: "Confidence threshold below which segments are treated as silence rather than transcribed. Default 0.6.", default: 0.6 },
1060
+ privacy_mode: { type: "boolean", description: "Override privacy mode for this call. true = metadata only, no transcript text transmitted to API. false = return text (even if WHISPER_PRIVACY_MODE=true globally). Omit to use global WHISPER_PRIVACY_MODE setting. When active, requires confirmation before each operation." },
1061
+ temperature: { type: "number", description: "Sampling temperature 0.0–1.0. Default 0.0 (deterministic)." },
1062
+ prompt: { type: "string", description: "Prior context string injected before transcription. Improves accuracy for domain-specific vocabulary or speaker names. Example: 'Names: Keemstar, DramaAlert.'" },
1063
+ condition_on_prev_text: { type: "boolean", description: "Re-enable conditioning each segment on its own prior output. Default false.", default: false },
1064
+ no_speech_thold: { type: "number", description: "Confidence threshold below which segments are treated as silence. Default 0.6.", default: 0.6 },
757
1065
  beam_size: { type: "number", description: "Beam search width. Higher = more accurate but slower. Default 5." },
758
1066
  best_of: { type: "number", description: "Number of candidate sequences to evaluate. Default 5." },
759
- gpu_device: { type: "number", description: "GPU device index for multi-GPU systems. Use check_system to see available GPUs. Default 0." },
760
- processors: { type: "number", description: "Number of parallel processors for chunk processing. Default 1." },
761
- word_timestamps: { type: "boolean", description: "Output one word per timestamped segment (sets --max-len 1 --split-on-word). Useful for clip alignment and precise timecode search.", default: false },
762
- max_segment_length: { type: "number", description: "Maximum segment length in characters. Controls line break frequency in output. Ignored when word_timestamps=true." },
763
- split_on_word: { type: "boolean", description: "Split segments at word boundaries rather than mid-word. Defaults to false.", default: false },
764
- diarize: { type: "boolean", description: "Stereo speaker diarization — labels left/right channel speakers in transcript. Requires stereo audio with speakers on separate channels.", default: false },
765
- vad_model: { type: "string", description: "Absolute path to a Silero VAD model .bin file. When provided, voice activity detection strips silence before transcription — reduces hallucinations and speeds up processing. Download via download_model." },
766
- offset_t: { type: "number", description: "Start transcription at this offset in milliseconds. Use to process a specific section of a file." },
767
- duration: { type: "number", description: "Process only this many milliseconds of audio starting from offset_t (or the beginning). Use with offset_t to target a specific time window." },
1067
+ gpu_device: { type: "number", description: "GPU device index for multi-GPU systems. Default 0." },
1068
+ processors: { type: "number", description: "Number of parallel processors. Default 1." },
1069
+ word_timestamps: { type: "boolean", description: "Output one word per timestamped segment. Useful for clip alignment.", default: false },
1070
+ max_segment_length: { type: "number", description: "Maximum segment length in characters." },
1071
+ split_on_word: { type: "boolean", description: "Split segments at word boundaries.", default: false },
1072
+ diarize: { type: "boolean", description: "Stereo speaker diarization — requires stereo audio with speakers on separate channels.", default: false },
1073
+ vad_model: { type: "string", description: "Absolute path to a Silero VAD model .bin file. Strips silence before transcription." },
1074
+ offset_t: { type: "number", description: "Start transcription at this offset in milliseconds." },
1075
+ duration: { type: "number", description: "Process only this many milliseconds of audio from offset_t." },
768
1076
  },
769
1077
  required: ["file_path"],
770
1078
  },
@@ -773,11 +1081,14 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
773
1081
  name: "check_progress",
774
1082
  description: "Check the status of a background transcription job started with transcribe_audio (background=true). " +
775
1083
  "Returns current progress, elapsed time, last processed timestamp, and the transcript when complete. " +
776
- "Call this repeatedly until the job shows as complete or failed.",
1084
+ "Call this repeatedly until the job shows as complete or failed. " +
1085
+ "⚠️ Privacy: transcript text returned on completion is processed by Claude's API. " +
1086
+ "Pass privacy_mode=true to return metadata only for this check, regardless of how the job was started.",
777
1087
  inputSchema: {
778
1088
  type: "object",
779
1089
  properties: {
780
1090
  job_id: { type: "string", description: "Job ID returned by transcribe_audio when background=true." },
1091
+ privacy_mode: { type: "boolean", description: "Override privacy mode for this check. true = metadata only. Omit to use the setting from when the job was started." },
781
1092
  },
782
1093
  required: ["job_id"],
783
1094
  },
@@ -789,19 +1100,24 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
789
1100
  "Saves each transcript as a .txt file next to its source. " +
790
1101
  "Files already transcribed (with matching .txt) are shown as done and skipped. " +
791
1102
  "Supported formats: mp3, wav, mp4, mkv, avi, mov, webm, m4a, flac, ogg. " +
792
- "NOTE: For large unattended batch jobs, use whisper-cli.exe directly from the command line " +
793
- "— see TROUBLESHOOTING.md for the command syntax.",
1103
+ "NOTE: For large unattended batch jobs, use start_batch instead. " +
1104
+ "⚠️ Privacy: transcript previews are processed by Claude's API. " +
1105
+ "Pass privacy_mode=true to suppress previews and return metadata only. " +
1106
+ "When privacy mode is active, confirmation is required before each file.",
794
1107
  inputSchema: {
795
1108
  type: "object",
796
1109
  properties: {
797
1110
  folder_path: { type: "string", description: "Absolute Windows path to the folder." },
798
- file_index: {
799
- type: "number",
800
- description: "Which file to process (1-based). Omit to list files first.",
801
- },
1111
+ file_index: { type: "number", description: "Which file to process (1-based). Omit to list files first." },
802
1112
  language: { type: "string", description: "Language code. Defaults to en.", default: "en" },
803
1113
  threads: { type: "number", description: `CPU threads. Defaults to ${WHISPER_THREADS} of ${SYSTEM_THREADS}.` },
804
1114
  recursive: { type: "boolean", description: "Include subfolders. Defaults to false.", default: false },
1115
+ output_format: {
1116
+ type: "string", enum: ["timestamps", "text"],
1117
+ description: "timestamps = with time codes (default), text = plain.",
1118
+ default: "timestamps",
1119
+ },
1120
+ privacy_mode: { type: "boolean", description: "Override privacy mode for this call. When active, requires confirmation before each file and returns metadata only." },
805
1121
  },
806
1122
  required: ["folder_path"],
807
1123
  },
@@ -811,22 +1127,23 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
811
1127
  description: "Generate subtitle files for an audio or video file using whisper.cpp. " +
812
1128
  "Set language='auto' to detect the spoken language automatically. " +
813
1129
  "Set translate_to_english=true to also generate an English translation subtitle file. " +
814
- "When both are requested, two .srt files are saved: one in the original language (e.g. film.ja.srt) " +
815
- "and one English translation (film.en.srt). " +
816
- "Load in VLC via Subtitle → Add Subtitle File. " +
1130
+ "Supports SRT and WebVTT (VTT) output formats. " +
1131
+ "When both native and translation are requested, two files are saved: one in the original language and one English translation. " +
1132
+ "Load SRT in VLC via Subtitle → Add Subtitle File. VTT works in web players and HTML5 video. " +
817
1133
  "Supports all standard formats plus .3gp and .ts.",
818
1134
  inputSchema: {
819
1135
  type: "object",
820
1136
  properties: {
821
1137
  file_path: { type: "string", description: "Absolute Windows path to the file." },
822
- language: {
823
- type: "string",
824
- description: "Language code (e.g. ja, es, fr, de) or 'auto' to detect automatically. Defaults to en.",
825
- default: "en",
1138
+ language: { type: "string", description: "Language code (e.g. ja, es, fr, de) or 'auto' to detect automatically. Defaults to en.", default: "en" },
1139
+ output_format: {
1140
+ type: "string", enum: ["srt", "vtt"],
1141
+ description: "srt = SubRip subtitle (default, widest compatibility), vtt = WebVTT (web and HTML5 video).",
1142
+ default: "srt",
826
1143
  },
827
1144
  translate_to_english: {
828
1145
  type: "boolean",
829
- description: "Also generate an English translation .srt alongside the native language .srt. Only applies when language is not 'en'. Not available in background mode.",
1146
+ description: "Also generate an English translation subtitle file alongside the native language file. Only applies when language is not 'en'. Not available in background mode.",
830
1147
  default: false,
831
1148
  },
832
1149
  background: {
@@ -839,8 +1156,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
839
1156
  prompt: { type: "string", description: "Prior context string for domain-specific vocabulary or speaker names." },
840
1157
  beam_size: { type: "number", description: "Beam search width. Higher = more accurate, slower. Default 5." },
841
1158
  best_of: { type: "number", description: "Candidate sequences evaluated. Default 5." },
842
- diarize: { type: "boolean", description: "Stereo speaker diarization. Requires stereo audio with speakers on separate channels.", default: false },
843
- vad_model: { type: "string", description: "Path to Silero VAD model .bin. Strips silence before transcription. Download via download_model." },
1159
+ diarize: { type: "boolean", description: "Stereo speaker diarization. Requires stereo audio.", default: false },
1160
+ vad_model: { type: "string", description: "Path to Silero VAD model .bin. Strips silence before transcription." },
844
1161
  },
845
1162
  required: ["file_path"],
846
1163
  },
@@ -856,13 +1173,22 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
856
1173
  "Scans for files without a matching .txt, sorts by duration (shortest first), " +
857
1174
  "and processes them one at a time as background jobs. " +
858
1175
  "Each file is validated after completion — empty or suspiciously short outputs are flagged. " +
859
- "Returns a batch ID to use with check_batch_progress.",
1176
+ "Batch self-advances without polling when each file finishes. " +
1177
+ "Returns a batch ID to use with check_batch_progress. " +
1178
+ "⚠️ Privacy: when privacy_mode is active, one confirmation is required before the batch starts. " +
1179
+ "All files then process unattended. No transcript text is returned to the API.",
860
1180
  inputSchema: {
861
1181
  type: "object",
862
1182
  properties: {
863
1183
  folder_path: { type: "string", description: "Absolute Windows path to the folder." },
864
1184
  language: { type: "string", description: "Language code. Defaults to en.", default: "en" },
865
1185
  threads: { type: "number", description: `CPU threads. Defaults to ${WHISPER_THREADS} of ${SYSTEM_THREADS}.` },
1186
+ output_format: {
1187
+ type: "string", enum: ["timestamps", "text"],
1188
+ description: "timestamps = with time codes (default), text = plain. Applies to all files in the batch.",
1189
+ default: "timestamps",
1190
+ },
1191
+ privacy_mode: { type: "boolean", description: "Override privacy mode for this batch. When active, requires one confirmation before batch start. All files process unattended with no transcript text returned." },
866
1192
  },
867
1193
  required: ["folder_path"],
868
1194
  },
@@ -890,10 +1216,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
890
1216
  inputSchema: {
891
1217
  type: "object",
892
1218
  properties: {
893
- path: {
894
- type: "string",
895
- description: "Absolute Windows path to a single file or a folder.",
896
- },
1219
+ path: { type: "string", description: "Absolute Windows path to a single file or a folder." },
897
1220
  sort_by: {
898
1221
  type: "string",
899
1222
  enum: ["duration", "name", "size"],
@@ -908,16 +1231,14 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
908
1231
  name: "check_system",
909
1232
  description: "Detect GPU hardware and verify Vulkan acceleration is available. " +
910
1233
  "Reports GPU name, VRAM, whether the Vulkan binary is installed, " +
911
- "and recommends the best Whisper model for your hardware. " +
912
- "Run this if you want to confirm GPU acceleration is working or diagnose why it isn't.",
1234
+ "and recommends the best Whisper model for your hardware.",
913
1235
  inputSchema: { type: "object", properties: {} },
914
1236
  },
915
1237
  {
916
1238
  name: "list_models",
917
1239
  description: "List all Whisper model files installed in your models directory. " +
918
1240
  "Shows filename, size, whether it is currently active, quantization status, " +
919
- "and recommended use case for each model. " +
920
- "No network calls — reads local filesystem only.",
1241
+ "and recommended use case for each model. No network calls — reads local filesystem only.",
921
1242
  inputSchema: { type: "object", properties: {} },
922
1243
  },
923
1244
  {
@@ -929,10 +1250,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
929
1250
  inputSchema: {
930
1251
  type: "object",
931
1252
  properties: {
932
- model_name: {
933
- type: "string",
934
- description: "Model name to download, e.g. 'large-v3-turbo', 'medium.en-q5_0', 'large-v3-turbo-q5_0'. Use list_models to see what is already installed.",
935
- },
1253
+ model_name: { type: "string", description: "Model name to download, e.g. 'large-v3-turbo', 'medium.en-q5_0'. Use list_models to see what is already installed." },
936
1254
  },
937
1255
  required: ["model_name"],
938
1256
  },
@@ -942,15 +1260,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
942
1260
  description: "Switch the active Whisper model for the current session without restarting Claude Desktop. " +
943
1261
  "Accepts a model filename (e.g. ggml-large-v3-turbo.bin) or full path. " +
944
1262
  "The model must already be installed in your models directory. " +
945
- "Use list_models to see installed models, download_model to add new ones. " +
946
1263
  "Change is session-scoped — does not persist after Claude Desktop restarts.",
947
1264
  inputSchema: {
948
1265
  type: "object",
949
1266
  properties: {
950
- model_name: {
951
- type: "string",
952
- description: "Model filename (e.g. ggml-large-v3-turbo.bin) or full path. Must be a .bin file in the configured models directory.",
953
- },
1267
+ model_name: { type: "string", description: "Model filename (e.g. ggml-large-v3-turbo.bin) or full path. Must be a .bin file in the configured models directory." },
954
1268
  },
955
1269
  required: ["model_name"],
956
1270
  },
@@ -980,8 +1294,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
980
1294
  `whisper-cli: ${WHISPER_CLI_PATH}\n` +
981
1295
  `Model: ${WHISPER_MODEL}\n` +
982
1296
  `Threads: ${WHISPER_THREADS} of ${SYSTEM_THREADS} logical cores\n` +
983
- `FFmpeg: ${ffmpegStatus}\n\n` +
984
- `Optional env vars: WHISPER_THREADS, FFMPEG_PATH`,
1297
+ `FFmpeg: ${ffmpegStatus}\n` +
1298
+ `Privacy mode: ${WHISPER_PRIVACY_MODE ? "✅ active (WHISPER_PRIVACY_MODE=true)" : "off"}\n\n` +
1299
+ `Optional env vars: WHISPER_THREADS, FFMPEG_PATH, WHISPER_PRIVACY_MODE, WHISPER_CONSENT_ACKNOWLEDGED`,
985
1300
  }],
986
1301
  };
987
1302
  }
@@ -995,7 +1310,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
995
1310
  return { content: [{ type: "text", text: "path is required." }], isError: true };
996
1311
  if (!existsSync(targetPath))
997
1312
  return { content: [{ type: "text", text: `Path not found: ${targetPath}` }], isError: true };
998
- // Check ffprobe is available
999
1313
  const ffprobePath = FFMPEG_PATH.replace(/ffmpeg(\.exe)?$/i, "ffprobe$1").replace(/ffmpeg$/i, "ffprobe");
1000
1314
  try {
1001
1315
  await execFileAsync(ffprobePath, ["-version"], { windowsHide: true });
@@ -1007,7 +1321,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1007
1321
  };
1008
1322
  }
1009
1323
  const vulkan = hasVulkanDll();
1010
- // Single file
1011
1324
  const stat = statSync(targetPath);
1012
1325
  if (stat.isFile()) {
1013
1326
  const info = await probeFile(targetPath);
@@ -1032,7 +1345,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1032
1345
  }],
1033
1346
  };
1034
1347
  }
1035
- // Folder scan
1036
1348
  const files = getFiles(targetPath, false);
1037
1349
  if (files.length === 0) {
1038
1350
  return { content: [{ type: "text", text: `No supported media files found in: ${targetPath}` }], isError: true };
@@ -1043,13 +1355,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1043
1355
  if (info)
1044
1356
  results.push(info);
1045
1357
  }
1046
- // Sort
1047
1358
  if (sortBy === "name")
1048
1359
  results.sort((a, b) => a.fileName.localeCompare(b.fileName));
1049
1360
  else if (sortBy === "size")
1050
1361
  results.sort((a, b) => a.sizeMb - b.sizeMb);
1051
1362
  else
1052
- results.sort((a, b) => a.durationSec - b.durationSec); // duration default
1363
+ results.sort((a, b) => a.durationSec - b.durationSec);
1053
1364
  const totalDuration = results.reduce((acc, r) => acc + r.durationSec, 0);
1054
1365
  const totalSize = results.reduce((acc, r) => acc + r.sizeMb, 0);
1055
1366
  const transcribedCount = results.filter(r => existsSync(r.filePath.replace(/\.[^.]+$/, ".txt"))).length;
@@ -1151,7 +1462,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1151
1462
  const useCase = known ? known.useCase : "Unknown model";
1152
1463
  return `${isActive ? "●" : "○"} ${f}${isActive}${quantTag}\n Size: ${sizeMb} | ${useCase}`;
1153
1464
  });
1154
- // Also list downloadable models not yet installed
1155
1465
  const installedFilenames = new Set(files);
1156
1466
  const available = MODEL_REGISTRY
1157
1467
  .filter(m => !installedFilenames.has(m.filename))
@@ -1188,7 +1498,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1188
1498
  isError: true,
1189
1499
  };
1190
1500
  }
1191
- // Security: enforce URL whitelist — never download from arbitrary URLs
1192
1501
  const urlOk = ALLOWED_HF_PREFIXES.some(prefix => entry.url.startsWith(prefix));
1193
1502
  if (!urlOk) {
1194
1503
  return {
@@ -1216,7 +1525,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1216
1525
  }],
1217
1526
  };
1218
1527
  }
1219
- // Download using Node.js built-in https — no external dependencies
1220
1528
  try {
1221
1529
  const https = await import("https");
1222
1530
  const fs = await import("fs");
@@ -1225,10 +1533,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1225
1533
  const file = fs.createWriteStream(tmpPath);
1226
1534
  function doRequest(url) {
1227
1535
  https.get(url, (res) => {
1228
- // Follow redirects (Hugging Face uses redirects)
1229
1536
  if ((res.statusCode === 301 || res.statusCode === 302 || res.statusCode === 307) && res.headers.location) {
1230
1537
  const redirectUrl = res.headers.location;
1231
- // Security: ensure redirect stays within allowed domains
1232
1538
  const redirectOk = ALLOWED_HF_PREFIXES.some(p => redirectUrl.startsWith(p))
1233
1539
  || redirectUrl.startsWith("https://cdn-lfs.huggingface.co/")
1234
1540
  || redirectUrl.startsWith("https://cdn-lfs-us-1.huggingface.co/");
@@ -1244,8 +1550,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1244
1550
  return;
1245
1551
  }
1246
1552
  res.pipe(file);
1247
- // Wait for close callback before renaming — Windows requires the file
1248
- // handle to be fully released before renameSync will succeed.
1249
1553
  file.on("finish", () => {
1250
1554
  file.close((closeErr) => {
1251
1555
  if (closeErr) {
@@ -1295,26 +1599,22 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1295
1599
  const modelInput = args?.model_name?.trim();
1296
1600
  if (!modelInput)
1297
1601
  return { content: [{ type: "text", text: "model_name is required." }], isError: true };
1298
- // Security: must end in .bin
1299
1602
  if (!modelInput.endsWith(".bin")) {
1300
1603
  return {
1301
1604
  content: [{ type: "text", text: `Invalid model: "${modelInput}"\nModel files must end in .bin` }],
1302
1605
  isError: true,
1303
1606
  };
1304
1607
  }
1305
- // Security: reject path traversal
1306
1608
  if (UNSAFE_PATH_RE.test(modelInput)) {
1307
1609
  return {
1308
1610
  content: [{ type: "text", text: `Invalid path: "${modelInput}"\nPaths containing ".." or UNC paths are not allowed.` }],
1309
1611
  isError: true,
1310
1612
  };
1311
1613
  }
1312
- // Resolve to full path — either absolute or relative to models dir
1313
1614
  const modelsDir = dirname(WHISPER_MODEL);
1314
1615
  const resolvedPath = modelInput.includes("\\") || modelInput.includes("/")
1315
1616
  ? modelInput
1316
1617
  : join(modelsDir, modelInput);
1317
- // Security: must live within the configured models directory
1318
1618
  if (!resolvedPath.startsWith(modelsDir)) {
1319
1619
  return {
1320
1620
  content: [{ type: "text", text: `Security error: model must be within the configured models directory (${modelsDir}).` }],
@@ -1331,7 +1631,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1331
1631
  isError: true,
1332
1632
  };
1333
1633
  }
1334
- // Process lock — don't switch mid-transcription
1335
1634
  if (await isWhisperRunning()) {
1336
1635
  return {
1337
1636
  content: [{ type: "text", text: "Cannot switch model while a transcription is in progress. Wait for the current job to finish first." }],
@@ -1361,11 +1660,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1361
1660
  const filePath = args?.file_path;
1362
1661
  const model = args?.model || WHISPER_MODEL;
1363
1662
  const language = args?.language || "en";
1364
- const outputFormat = (args?.output_format || "text");
1663
+ const outputFormat = (args?.output_format || "timestamps");
1365
1664
  const threads = Math.min(SYSTEM_THREADS, Math.max(1, Math.round(args?.threads || WHISPER_THREADS)));
1366
- const saveToFile = args?.save_to_file || false;
1665
+ const saveToFile = args?.save_to_file ?? true;
1367
1666
  const background = args?.background || false;
1368
- // v2.2.0 quality and control params
1667
+ // Effective privacy mode: per-call param wins over global env var
1668
+ const privacyModeParam = args?.privacy_mode;
1669
+ const effectivePrivacyMode = privacyModeParam ?? WHISPER_PRIVACY_MODE;
1670
+ // v2.3.0 quality and control params
1369
1671
  const extraOpts = {};
1370
1672
  if (args?.temperature !== undefined)
1371
1673
  extraOpts.temperature = Number(args.temperature);
@@ -1409,6 +1711,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1409
1711
  return { content: [{ type: "text", text: configError }], isError: true };
1410
1712
  // Background mode — detached process, returns immediately
1411
1713
  if (background) {
1714
+ // Privacy mode: gate fires BEFORE spawning. No audio processes until confirmed.
1715
+ // Non-privacy mode: consent gate is intentionally deferred to check_progress.
1716
+ // At this point no transcript exists yet — there is nothing to gate. The gate
1717
+ // fires at check_progress completion when transcript text would first be returned
1718
+ // to the API. Audio processing begins immediately after this point in non-privacy mode.
1719
+ if (effectivePrivacyMode && checkPrivacyGate()) {
1720
+ return { content: [{ type: "text", text: privacyGateBlock() }] };
1721
+ }
1412
1722
  if (await isWhisperRunning()) {
1413
1723
  return {
1414
1724
  content: [{ type: "text", text: "Transcription already in progress. Wait for the current job to finish before starting another." }],
@@ -1416,15 +1726,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1416
1726
  };
1417
1727
  }
1418
1728
  try {
1419
- const { jobId, pid } = await spawnDetached(filePath, model, language, threads, outputFormat === "srt" ? "srt" : "text", extraOpts);
1729
+ const bgFormat = outputFormat === "json" ? "text" : outputFormat;
1730
+ const { jobId, pid } = await spawnDetached(filePath, model, language, threads, bgFormat, extraOpts, undefined, effectivePrivacyMode);
1420
1731
  return {
1421
1732
  content: [{
1422
1733
  type: "text",
1423
1734
  text: `⏳ Background transcription started.\n\n` +
1424
1735
  `Source: ${basename(filePath)}\n` +
1425
1736
  `Job ID: ${jobId}\n` +
1426
- `PID: ${pid}\n\n` +
1427
- `Call check_progress with job_id="${jobId}" to monitor progress.\n` +
1737
+ `PID: ${pid}\n` +
1738
+ (effectivePrivacyMode ? `Privacy mode: active — metadata only will be returned\n` : "") +
1739
+ `\nCall check_progress with job_id="${jobId}" to monitor progress.\n` +
1428
1740
  `Output will be saved to: ${filePath.replace(/\.[^.]+$/, ".txt")}`,
1429
1741
  }],
1430
1742
  };
@@ -1434,13 +1746,40 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1434
1746
  }
1435
1747
  }
1436
1748
  // Blocking mode (default)
1749
+ if (effectivePrivacyMode) {
1750
+ // Privacy mode: gate fires before every operation.
1751
+ if (checkPrivacyGate()) {
1752
+ return { content: [{ type: "text", text: privacyGateBlock() }] };
1753
+ }
1754
+ // Gate passed — proceed to transcription, return metadata only.
1755
+ }
1756
+ else {
1757
+ // Non-privacy mode: session consent gate fires once before first transcript return.
1758
+ // Nothing is processed until user confirms.
1759
+ const policy = transcriptPolicy();
1760
+ if (policy === "consent_gate") {
1761
+ return { content: [{ type: "text", text: consentGateBlock() }] };
1762
+ }
1763
+ }
1437
1764
  try {
1438
1765
  const result = await transcribeSingle(filePath, model, language, outputFormat, threads, saveToFile, extraOpts);
1766
+ if (effectivePrivacyMode) {
1767
+ const savedPath = result.savedTo ?? filePath.replace(/\.[^.]+$/, ".txt");
1768
+ if (!result.savedTo && outputFormat !== "srt" && outputFormat !== "vtt" && outputFormat !== "json") {
1769
+ try {
1770
+ writeFileSync(savedPath, result.text, "utf8");
1771
+ }
1772
+ catch { }
1773
+ }
1774
+ const displayPath = result.srtPath ?? result.savedTo ?? savedPath;
1775
+ return { content: [{ type: "text", text: privacyModeBlock(basename(filePath), displayPath, result.text) }] };
1776
+ }
1777
+ // allow — return transcript normally
1439
1778
  let response = result.text;
1440
1779
  if (result.savedTo)
1441
1780
  response += `\n\n[Transcript saved to: ${result.savedTo}]`;
1442
1781
  if (result.srtPath)
1443
- response += `\n\n[SRT subtitle file saved to: ${result.srtPath}]`;
1782
+ response += `\n\n[Subtitle file saved to: ${result.srtPath}]`;
1444
1783
  return { content: [{ type: "text", text: response }] };
1445
1784
  }
1446
1785
  catch (err) {
@@ -1452,10 +1791,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1452
1791
  // -------------------------------------------------------------------------
1453
1792
  if (name === "check_progress") {
1454
1793
  const jobId = args?.job_id;
1794
+ const privacyModeParam = args?.privacy_mode;
1455
1795
  if (!jobId)
1456
1796
  return { content: [{ type: "text", text: "job_id is required." }], isError: true };
1457
1797
  try {
1458
- const result = await readJobProgress(jobId);
1798
+ const result = await readJobProgress(jobId, privacyModeParam);
1459
1799
  return { content: [{ type: "text", text: result }] };
1460
1800
  }
1461
1801
  catch (err) {
@@ -1469,6 +1809,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1469
1809
  const folderPath = args?.folder_path;
1470
1810
  const language = args?.language || "en";
1471
1811
  const threads = Math.min(SYSTEM_THREADS, Math.max(1, Math.round(args?.threads || WHISPER_THREADS)));
1812
+ const outputFormat = (args?.output_format || "timestamps");
1813
+ const privacyModeParam = args?.privacy_mode;
1814
+ const effectivePrivacyMode = privacyModeParam ?? WHISPER_PRIVACY_MODE;
1472
1815
  if (!folderPath)
1473
1816
  return { content: [{ type: "text", text: "folder_path is required." }], isError: true };
1474
1817
  const pathError = validateInputPath(folderPath);
@@ -1479,16 +1822,19 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1479
1822
  const configError = validatePaths();
1480
1823
  if (configError)
1481
1824
  return { content: [{ type: "text", text: configError }], isError: true };
1825
+ // Privacy gate: fires once before batch starts. All files then process unattended.
1826
+ // Gating per-file in an unattended batch would defeat the purpose of start_batch.
1827
+ if (effectivePrivacyMode && checkPrivacyGate()) {
1828
+ return { content: [{ type: "text", text: privacyGateBlock() }] };
1829
+ }
1482
1830
  if (await isWhisperRunning()) {
1483
1831
  return { content: [{ type: "text", text: "A transcription is already running. Wait for it to finish before starting a batch." }], isError: true };
1484
1832
  }
1485
- // Scan for untranscribed files
1486
1833
  const allFiles = getFiles(folderPath, false);
1487
1834
  const untranscribed = allFiles.filter(f => !existsSync(f.replace(/\.[^.]+$/, ".txt")));
1488
1835
  if (untranscribed.length === 0) {
1489
1836
  return { content: [{ type: "text", text: `✅ All files in ${folderPath} are already transcribed. Nothing to do.` }] };
1490
1837
  }
1491
- // Probe durations for sorting
1492
1838
  const batchFiles = [];
1493
1839
  for (const f of untranscribed) {
1494
1840
  const info = await probeFile(f);
@@ -1514,6 +1860,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1514
1860
  model: WHISPER_MODEL,
1515
1861
  language,
1516
1862
  threads,
1863
+ outputFormat,
1864
+ privacyMode: effectivePrivacyMode,
1517
1865
  };
1518
1866
  writeFileSync(batchPath, JSON.stringify(state, null, 2), "utf8");
1519
1867
  await spawnNextBatchJob(state);
@@ -1526,8 +1874,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1526
1874
  `Folder: ${folderPath}\n` +
1527
1875
  `Files to process: ${batchFiles.length}\n` +
1528
1876
  `Total audio: ${formatDuration(totalDuration)}\n` +
1529
- `Est. GPU time: ${estimateTime(totalDuration, hasVulkanDll())}\n\n` +
1530
- `First file: ${batchFiles[0].fileName}\n\n` +
1877
+ `Est. GPU time: ${estimateTime(totalDuration, hasVulkanDll())}\n` +
1878
+ (effectivePrivacyMode ? `Privacy mode: active — metadata only will be returned\n` : "") +
1879
+ `\nFirst file: ${batchFiles[0].fileName}\n\n` +
1531
1880
  `Call check_batch_progress with batch_id="${batchId}" to monitor.`,
1532
1881
  }],
1533
1882
  };
@@ -1553,10 +1902,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1553
1902
  if (name === "generate_subtitles") {
1554
1903
  const filePath = args?.file_path;
1555
1904
  const language = args?.language || "en";
1905
+ const subtitleFormat = (args?.output_format || "srt");
1556
1906
  const translateToEnglish = args?.translate_to_english || false;
1557
1907
  const background = args?.background || false;
1558
1908
  const threads = Math.min(SYSTEM_THREADS, Math.max(1, Math.round(args?.threads || WHISPER_THREADS)));
1559
- // v2.2.0 quality params
1909
+ // v2.3.0 quality params
1560
1910
  const extraOpts = {};
1561
1911
  if (args?.temperature !== undefined)
1562
1912
  extraOpts.temperature = Number(args.temperature);
@@ -1583,10 +1933,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1583
1933
  if (await isWhisperRunning()) {
1584
1934
  return { content: [{ type: "text", text: "Transcription already in progress. Wait for it to finish first." }], isError: true };
1585
1935
  }
1586
- // Background mode — detached SRT job
1587
1936
  if (background) {
1588
1937
  try {
1589
- const { jobId, pid } = await spawnDetached(filePath, WHISPER_MODEL, language, threads, "srt", extraOpts);
1938
+ const { jobId, pid } = await spawnDetached(filePath, WHISPER_MODEL, language, threads, subtitleFormat, extraOpts);
1590
1939
  return {
1591
1940
  content: [{
1592
1941
  type: "text",
@@ -1594,6 +1943,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1594
1943
  `Source: ${basename(filePath)}\n` +
1595
1944
  `Job ID: ${jobId}\n` +
1596
1945
  `PID: ${pid}\n` +
1946
+ `Format: ${subtitleFormat.toUpperCase()}\n` +
1597
1947
  `Language: ${language}\n\n` +
1598
1948
  `Call check_progress with job_id="${jobId}" to monitor.\n` +
1599
1949
  `Note: translate_to_english is not available in background mode. ` +
@@ -1606,7 +1956,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1606
1956
  }
1607
1957
  }
1608
1958
  try {
1609
- // Convert to WAV if needed
1610
1959
  let transcribeFrom = filePath;
1611
1960
  let tmpFile = null;
1612
1961
  if (needsConversion(filePath)) {
@@ -1614,26 +1963,23 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1614
1963
  transcribeFrom = tmpFile;
1615
1964
  }
1616
1965
  const baseNoExt = filePath.replace(/\.[^.]+$/, "");
1617
- // Auto-detect language if requested
1966
+ const ext = subtitleFormat === "vtt" ? ".vtt" : ".srt";
1618
1967
  let detectedLang = language;
1619
1968
  if (language === "auto") {
1620
1969
  const detected = await detectLanguage(transcribeFrom, WHISPER_MODEL, threads);
1621
1970
  detectedLang = detected ?? "en";
1622
1971
  }
1623
1972
  const results = [];
1624
- // Pass 1 — native language SRT
1625
- const nativeSrt = language === "en" || detectedLang === "en"
1626
- ? `${baseNoExt}.srt`
1627
- : `${baseNoExt}.${detectedLang}.srt`;
1628
- await runSrtPass(transcribeFrom, nativeSrt, WHISPER_MODEL, detectedLang, threads, false, extraOpts);
1629
- results.push(`✅ Native (${detectedLang}): ${nativeSrt}`);
1630
- // Pass 2 — English translation SRT (only if language isn't already English)
1973
+ const nativeDest = language === "en" || detectedLang === "en"
1974
+ ? `${baseNoExt}${ext}`
1975
+ : `${baseNoExt}.${detectedLang}${ext}`;
1976
+ await runSubtitlePass(transcribeFrom, nativeDest, subtitleFormat, WHISPER_MODEL, detectedLang, threads, false, extraOpts);
1977
+ results.push(`✅ Native (${detectedLang}): ${nativeDest}`);
1631
1978
  if (translateToEnglish && detectedLang !== "en") {
1632
- const englishSrt = `${baseNoExt}.en.srt`;
1633
- await runSrtPass(transcribeFrom, englishSrt, WHISPER_MODEL, detectedLang, threads, true, extraOpts);
1634
- results.push(`✅ English translation: ${englishSrt}`);
1979
+ const englishDest = `${baseNoExt}.en${ext}`;
1980
+ await runSubtitlePass(transcribeFrom, englishDest, subtitleFormat, WHISPER_MODEL, detectedLang, threads, true, extraOpts);
1981
+ results.push(`✅ English translation: ${englishDest}`);
1635
1982
  }
1636
- // Clean up temp WAV
1637
1983
  if (tmpFile && existsSync(tmpFile))
1638
1984
  try {
1639
1985
  unlinkSync(tmpFile);
@@ -1642,16 +1988,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1642
1988
  const langNote = language === "auto"
1643
1989
  ? `Auto-detected language: ${detectedLang}\n\n`
1644
1990
  : "";
1991
+ const playerNote = subtitleFormat === "vtt"
1992
+ ? `Load in web players, HTML5 <video>, or any player that supports WebVTT.`
1993
+ : `Load in VLC via Subtitle → Add Subtitle File → select the .srt file.`;
1645
1994
  return {
1646
1995
  content: [{
1647
1996
  type: "text",
1648
1997
  text: `✅ Subtitle file(s) generated!\n\n` +
1649
1998
  langNote +
1650
1999
  results.join("\n") + "\n\n" +
1651
- `To use in VLC: Subtitle → Add Subtitle File → select the .srt file.\n` +
1652
- `Works in any video player that supports external subtitles.\n\n` +
2000
+ playerNote + "\n\n" +
1653
2001
  `Note: whisper's built-in translation only translates to English. ` +
1654
- `For other target languages, translate the .srt file contents separately.`,
2002
+ `For other target languages, translate the subtitle file contents separately.`,
1655
2003
  }],
1656
2004
  };
1657
2005
  }
@@ -1660,7 +2008,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1660
2008
  }
1661
2009
  }
1662
2010
  // -------------------------------------------------------------------------
1663
- // transcribe_batch (interactive only)
2011
+ // transcribe_batch (interactive)
1664
2012
  // -------------------------------------------------------------------------
1665
2013
  if (name === "transcribe_batch") {
1666
2014
  const folderPath = args?.folder_path;
@@ -1668,6 +2016,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1668
2016
  const threads = Math.min(SYSTEM_THREADS, Math.max(1, Math.round(args?.threads || WHISPER_THREADS)));
1669
2017
  const recursive = args?.recursive || false;
1670
2018
  const fileIndex = args?.file_index;
2019
+ const outputFormat = (args?.output_format || "timestamps");
2020
+ const privacyModeParam = args?.privacy_mode;
2021
+ const effectivePrivacyMode = privacyModeParam ?? WHISPER_PRIVACY_MODE;
1671
2022
  if (!folderPath)
1672
2023
  return { content: [{ type: "text", text: "folder_path is required." }], isError: true };
1673
2024
  if (!existsSync(folderPath))
@@ -1684,7 +2035,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1684
2035
  }],
1685
2036
  };
1686
2037
  }
1687
- // No file_index: return file list
1688
2038
  if (fileIndex === undefined) {
1689
2039
  return {
1690
2040
  content: [{
@@ -1696,11 +2046,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1696
2046
  return ` ${i + 1}. ${basename(f)}${done}`;
1697
2047
  }).join("\n") +
1698
2048
  `\n\nTo start, say "transcribe file 1" (or any number). I'll process one file at a time and wait for your go-ahead before continuing.\n` +
1699
- `\nFor large unattended batches, see the command line approach in TROUBLESHOOTING.md.`,
2049
+ `\nFor large unattended batches, use start_batch instead.`,
1700
2050
  }],
1701
2051
  };
1702
2052
  }
1703
- // Process the requested file
1704
2053
  const idx = fileIndex - 1;
1705
2054
  if (idx < 0 || idx >= files.length) {
1706
2055
  return { content: [{ type: "text", text: `Invalid file number. Choose between 1 and ${files.length}.` }], isError: true };
@@ -1709,17 +2058,42 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1709
2058
  const fileName = basename(filePath);
1710
2059
  const txtPath = filePath.replace(/\.[^.]+$/, ".txt");
1711
2060
  try {
1712
- const result = await transcribeSingle(filePath, WHISPER_MODEL, language, "text", threads, true, {});
2061
+ // v2.3.0: Privacy gate fires before each file in privacy mode.
2062
+ // Each file in transcribe_batch is a separate tool call, so each
2063
+ // gets its own confirmation — correct for interactive mode.
2064
+ if (effectivePrivacyMode && checkPrivacyGate()) {
2065
+ return { content: [{ type: "text", text: privacyGateBlock() }] };
2066
+ }
2067
+ // Non-privacy mode: session consent gate fires once before first file.
2068
+ if (!effectivePrivacyMode) {
2069
+ const policy = transcriptPolicy();
2070
+ if (policy === "consent_gate") {
2071
+ return { content: [{ type: "text", text: consentGateBlock() }] };
2072
+ }
2073
+ }
2074
+ const result = await transcribeSingle(filePath, WHISPER_MODEL, language, outputFormat, threads, true, {});
1713
2075
  const remaining = files.length - fileIndex;
1714
2076
  const nextMsg = remaining > 0
1715
2077
  ? `\n\n${remaining} file(s) remaining. Say "continue" or "transcribe file ${fileIndex + 1}" to proceed, or "stop" to finish.`
1716
2078
  : `\n\n✅ That was the last file. Batch complete!`;
2079
+ let bodyText;
2080
+ if (effectivePrivacyMode) {
2081
+ const words = estimateWordCount(result.text);
2082
+ bodyText =
2083
+ `Saved to: ${txtPath}\n` +
2084
+ `Words: ~${words}\n\n` +
2085
+ `Privacy mode active — transcript not returned to Claude's API.`;
2086
+ }
2087
+ else {
2088
+ bodyText =
2089
+ `Saved to: ${txtPath}\n\n` +
2090
+ `Preview:\n${result.text.slice(0, 500)}${result.text.length > 500 ? "..." : ""}`;
2091
+ }
1717
2092
  return {
1718
2093
  content: [{
1719
2094
  type: "text",
1720
2095
  text: `[${fileIndex}/${files.length}] ✅ ${fileName}\n\n` +
1721
- `Saved to: ${txtPath}\n\n` +
1722
- `Preview:\n${result.text.slice(0, 500)}${result.text.length > 500 ? "..." : ""}` +
2096
+ bodyText +
1723
2097
  nextMsg,
1724
2098
  }],
1725
2099
  };
@@ -1742,9 +2116,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1742
2116
  // Start
1743
2117
  // ---------------------------------------------------------------------------
1744
2118
  async function main() {
2119
+ cleanupOldJobFiles();
1745
2120
  const transport = new StdioServerTransport();
1746
2121
  await server.connect(transport);
1747
- console.error(`whisper-windows-mcp v2.2.0 running | threads: ${WHISPER_THREADS}/${SYSTEM_THREADS}`);
2122
+ console.error(`whisper-windows-mcp v2.3.0 running | threads: ${WHISPER_THREADS}/${SYSTEM_THREADS} | privacy: ${WHISPER_PRIVACY_MODE ? "on" : "off"}`);
1748
2123
  }
1749
2124
  main().catch((err) => {
1750
2125
  console.error("Fatal error:", err);