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.
- package/{LICENSE-COMMERCIAL.md → COMMERCIAL-LICENSE.md} +58 -58
- package/LICENSE +40 -40
- package/PRIVACY.es.md +192 -135
- package/PRIVACY.id.md +192 -135
- package/PRIVACY.ja.md +192 -135
- package/PRIVACY.ko.md +192 -135
- package/PRIVACY.md +192 -135
- package/PRIVACY.pl.md +192 -135
- package/PRIVACY.pt-BR.md +192 -135
- package/PRIVACY.ro.md +192 -135
- package/PRIVACY.uk.md +192 -135
- package/PRIVACY.vi.md +192 -135
- package/README.es.md +74 -48
- package/README.id.md +77 -40
- package/README.ja.md +100 -72
- package/README.ko.md +63 -37
- package/README.md +76 -39
- package/README.pl.md +77 -40
- package/README.pt-BR.md +71 -45
- package/README.ro.md +78 -41
- package/README.uk.md +77 -40
- package/README.vi.md +67 -41
- package/ROADMAP.es.md +110 -48
- package/ROADMAP.id.md +77 -104
- package/ROADMAP.ja.md +84 -123
- package/ROADMAP.ko.md +73 -97
- package/ROADMAP.pl.md +104 -44
- package/ROADMAP.pt-BR.md +78 -102
- package/ROADMAP.ro.md +102 -44
- package/ROADMAP.uk.md +65 -97
- package/ROADMAP.vi.md +78 -102
- package/SECURITY.es.md +64 -47
- package/SECURITY.id.md +64 -47
- package/SECURITY.ja.md +64 -47
- package/SECURITY.ko.md +64 -47
- package/SECURITY.md +21 -4
- package/SECURITY.pl.md +64 -47
- package/SECURITY.pt-BR.md +64 -47
- package/SECURITY.ro.md +64 -47
- package/SECURITY.uk.md +64 -47
- package/SECURITY.vi.md +64 -47
- package/TROUBLESHOOTING.es.md +309 -323
- package/TROUBLESHOOTING.id.md +333 -323
- package/TROUBLESHOOTING.ja.md +399 -286
- package/TROUBLESHOOTING.ko.md +309 -323
- package/TROUBLESHOOTING.pl.md +355 -323
- package/TROUBLESHOOTING.pt-BR.md +309 -323
- package/TROUBLESHOOTING.ro.md +355 -323
- package/TROUBLESHOOTING.uk.md +369 -323
- package/TROUBLESHOOTING.vi.md +309 -323
- package/dist/index.js +591 -216
- package/package.json +45 -45
- 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
|
-
//
|
|
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
|
|
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 = "
|
|
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
|
-
//
|
|
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"
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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"
|
|
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) ||
|
|
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
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
state.files[i].
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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 (
|
|
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 { /*
|
|
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"
|
|
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
|
|
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
|
|
940
|
+
async function runSubtitlePass(transcribeFrom, dest, format, model, language, threads, translate = false, extraOpts = {}) {
|
|
641
941
|
const opts = {
|
|
642
|
-
language, outputFormat:
|
|
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
|
|
651
|
-
|
|
652
|
-
|
|
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(
|
|
955
|
+
unlinkSync(tmpOut);
|
|
655
956
|
}
|
|
656
957
|
catch { }
|
|
657
958
|
}
|
|
658
|
-
return
|
|
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
|
|
688
|
-
const
|
|
689
|
-
|
|
690
|
-
|
|
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(
|
|
993
|
+
unlinkSync(tmpOut);
|
|
693
994
|
}
|
|
694
995
|
catch { }
|
|
695
996
|
}
|
|
696
|
-
return { text: output, srtPath:
|
|
997
|
+
return { text: output, srtPath: destOut };
|
|
697
998
|
}
|
|
698
999
|
if (saveToFile) {
|
|
699
|
-
const
|
|
700
|
-
|
|
701
|
-
|
|
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.
|
|
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
|
-
"
|
|
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: ["
|
|
747
|
-
description: "
|
|
748
|
-
default: "
|
|
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:
|
|
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
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
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.
|
|
760
|
-
processors: { type: "number", description: "Number of parallel processors
|
|
761
|
-
word_timestamps: { type: "boolean", description: "Output one word per timestamped segment
|
|
762
|
-
max_segment_length: { type: "number", description: "Maximum segment length in characters.
|
|
763
|
-
split_on_word: { type: "boolean", description: "Split segments at word boundaries
|
|
764
|
-
diarize: { type: "boolean", description: "Stereo speaker diarization —
|
|
765
|
-
vad_model: { type: "string", description: "Absolute path to a Silero VAD model .bin file.
|
|
766
|
-
offset_t: { type: "number", description: "Start transcription at this offset in milliseconds.
|
|
767
|
-
duration: { type: "number", description: "Process only this many milliseconds of audio
|
|
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
|
|
793
|
-
"
|
|
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
|
-
"
|
|
815
|
-
"and one English translation
|
|
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
|
-
|
|
824
|
-
|
|
825
|
-
|
|
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
|
|
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
|
|
843
|
-
vad_model: { type: "string", description: "Path to Silero VAD model .bin. Strips silence before transcription.
|
|
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
|
-
"
|
|
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
|
|
984
|
-
`
|
|
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);
|
|
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 || "
|
|
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
|
|
1665
|
+
const saveToFile = args?.save_to_file ?? true;
|
|
1367
1666
|
const background = args?.background || false;
|
|
1368
|
-
//
|
|
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
|
|
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
|
|
1427
|
-
`
|
|
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[
|
|
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
|
|
1530
|
-
`
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
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
|
|
1633
|
-
await
|
|
1634
|
-
results.push(`✅ English translation: ${
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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);
|