whisper-windows-mcp 1.5.1 → 1.7.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 (2) hide show
  1. package/dist/index.js +364 -6
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -7,8 +7,8 @@
7
7
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
8
8
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9
9
  import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
10
- import { execFile } from "child_process";
11
- import { existsSync, unlinkSync, readdirSync, writeFileSync, readFileSync } from "fs";
10
+ import { execFile, spawn } from "child_process";
11
+ import { existsSync, unlinkSync, readdirSync, writeFileSync, readFileSync, mkdirSync, openSync, closeSync, statSync, } from "fs";
12
12
  import { cpus, tmpdir } from "os";
13
13
  import { join, extname, basename, dirname } from "path";
14
14
  import { promisify } from "util";
@@ -53,6 +53,139 @@ async function isWhisperRunning() {
53
53
  return false;
54
54
  }
55
55
  }
56
+ // ---------------------------------------------------------------------------
57
+ // Background job architecture (Priorities 4 + 5)
58
+ // ---------------------------------------------------------------------------
59
+ const JOBS_DIR = join(tmpdir(), "whisper-mcp-jobs");
60
+ function ensureJobsDir() {
61
+ mkdirSync(JOBS_DIR, { recursive: true });
62
+ }
63
+ async function spawnDetached(filePath, model, language, threads) {
64
+ ensureJobsDir();
65
+ const jobId = `job_${Date.now()}`;
66
+ const logPath = join(JOBS_DIR, `${jobId}.log`);
67
+ const jobPath = join(JOBS_DIR, `${jobId}.json`);
68
+ // Convert to WAV first if needed (fast, blocking — keeps things simple)
69
+ let transcribeFrom = filePath;
70
+ let isTmp = false;
71
+ if (needsConversion(filePath)) {
72
+ transcribeFrom = await convertToWav(filePath);
73
+ isTmp = true;
74
+ }
75
+ // Build args — use -otxt so whisper writes outputfile.txt itself
76
+ const outputBase = filePath.replace(/\.[^.]+$/, "");
77
+ const outputPath = outputBase + ".txt";
78
+ const args = [
79
+ "-m", model,
80
+ "-f", transcribeFrom,
81
+ "-l", language,
82
+ "-t", String(threads),
83
+ "-otxt",
84
+ "-of", outputBase,
85
+ ];
86
+ // Spawn detached, redirect both stdout+stderr to log file
87
+ const logFd = openSync(logPath, "w");
88
+ const child = spawn(WHISPER_CLI_PATH, args, {
89
+ detached: true,
90
+ stdio: ["ignore", logFd, logFd],
91
+ windowsHide: true,
92
+ });
93
+ closeSync(logFd);
94
+ child.unref();
95
+ const pid = child.pid ?? 0;
96
+ const job = {
97
+ jobId,
98
+ pid,
99
+ sourceFile: filePath,
100
+ transcribeFrom,
101
+ isTmp,
102
+ outputPath,
103
+ logPath,
104
+ jobPath,
105
+ startTime: new Date().toISOString(),
106
+ model,
107
+ language,
108
+ threads,
109
+ durationSec: 0,
110
+ status: "running",
111
+ };
112
+ writeFileSync(jobPath, JSON.stringify(job, null, 2), "utf8");
113
+ return { jobId, pid };
114
+ }
115
+ async function isPidRunning(pid) {
116
+ try {
117
+ const { stdout } = await execFileAsync("tasklist", ["/FI", `PID eq ${pid}`, "/NH"], { windowsHide: true });
118
+ return stdout.toLowerCase().includes("whisper-cli.exe");
119
+ }
120
+ catch {
121
+ return false;
122
+ }
123
+ }
124
+ function parseLastTimestamp(logContent) {
125
+ // whisper outputs: [00:01:30.000 --> 00:01:35.000] text
126
+ const re = /\[(\d{2}):(\d{2}):(\d{2})\.\d{3} -->/g;
127
+ let lastSec = 0;
128
+ let m;
129
+ while ((m = re.exec(logContent)) !== null) {
130
+ const sec = parseInt(m[1], 10) * 3600 + parseInt(m[2], 10) * 60 + parseInt(m[3], 10);
131
+ if (sec > lastSec)
132
+ lastSec = sec;
133
+ }
134
+ return lastSec;
135
+ }
136
+ async function readJobProgress(jobId) {
137
+ const jobPath = join(JOBS_DIR, `${jobId}.json`);
138
+ if (!existsSync(jobPath)) {
139
+ return `❌ Job not found: ${jobId}\n\nThe job file may have been deleted or the ID is incorrect.`;
140
+ }
141
+ const job = JSON.parse(readFileSync(jobPath, "utf8"));
142
+ // Read log
143
+ let logContent = "";
144
+ if (existsSync(job.logPath)) {
145
+ logContent = readFileSync(job.logPath, "utf8");
146
+ }
147
+ const lastSec = parseLastTimestamp(logContent);
148
+ const isRunning = await isPidRunning(job.pid);
149
+ const outputExists = existsSync(job.outputPath);
150
+ // Completed
151
+ if (!isRunning && outputExists) {
152
+ job.status = "complete";
153
+ writeFileSync(job.jobPath, JSON.stringify(job, null, 2), "utf8");
154
+ // Clean up tmp wav if present
155
+ if (job.isTmp && existsSync(job.transcribeFrom)) {
156
+ try {
157
+ unlinkSync(job.transcribeFrom);
158
+ }
159
+ catch { }
160
+ }
161
+ const transcript = readFileSync(job.outputPath, "utf8").trim();
162
+ return (`✅ Complete!\n\n` +
163
+ `Source: ${basename(job.sourceFile)}\n` +
164
+ `Output: ${job.outputPath}\n\n` +
165
+ `Preview:\n${transcript.slice(0, 600)}${transcript.length > 600 ? "..." : ""}`);
166
+ }
167
+ // Failed
168
+ if (!isRunning && !outputExists) {
169
+ job.status = "failed";
170
+ writeFileSync(job.jobPath, JSON.stringify(job, null, 2), "utf8");
171
+ const lastLines = logContent.split(/\r?\n/).filter(l => l.trim()).slice(-5).join("\n");
172
+ return (`❌ Failed or cancelled.\n\n` +
173
+ `Source: ${basename(job.sourceFile)}\n` +
174
+ `No output found at: ${job.outputPath}\n\n` +
175
+ `Last log output:\n${lastLines || "(empty)"}`);
176
+ }
177
+ // Still running
178
+ const elapsed = Math.round((Date.now() - new Date(job.startTime).getTime()) / 1000);
179
+ const progressLine = lastSec > 0
180
+ ? `Last segment: ${formatDuration(lastSec)}`
181
+ : "Starting up...";
182
+ return (`⏳ In progress...\n\n` +
183
+ `Source: ${basename(job.sourceFile)}\n` +
184
+ `Job ID: ${jobId}\n` +
185
+ `Elapsed: ${formatDuration(elapsed)}\n` +
186
+ `${progressLine}\n\n` +
187
+ `Call check_progress with this job ID to get an update.`);
188
+ }
56
189
  async function detectGpus() {
57
190
  try {
58
191
  const { stdout } = await execFileAsync("wmic", ["path", "win32_VideoController", "get", "name,AdapterRAM", "/format:csv"], { windowsHide: true });
@@ -95,6 +228,60 @@ function hasVulkanDll() {
95
228
  const whisperDir = dirname(WHISPER_CLI_PATH);
96
229
  return existsSync(join(whisperDir, "ggml-vulkan.dll"));
97
230
  }
231
+ async function probeFile(filePath) {
232
+ try {
233
+ const { stdout } = await execFileAsync(FFMPEG_PATH.replace(/ffmpeg(\.exe)?$/i, "ffprobe$1").replace(/ffmpeg$/i, "ffprobe"), [
234
+ "-v", "quiet",
235
+ "-print_format", "json",
236
+ "-show_format",
237
+ "-show_streams",
238
+ filePath,
239
+ ], { windowsHide: true, maxBuffer: 10 * 1024 * 1024 });
240
+ const data = JSON.parse(stdout);
241
+ const fmt = data.format ?? {};
242
+ const streams = data.streams ?? [];
243
+ const audioStream = streams.find((s) => s.codec_type === "audio");
244
+ const durationSec = parseFloat(fmt.duration ?? "0") || 0;
245
+ const sizeMb = parseInt(fmt.size ?? "0", 10) / (1024 * 1024);
246
+ const bitrate = Math.round(parseInt(fmt.bit_rate ?? "0", 10) / 1000);
247
+ const codec = audioStream?.codec_name ?? fmt.format_name?.split(",")[0] ?? "unknown";
248
+ return {
249
+ filePath,
250
+ fileName: basename(filePath),
251
+ durationSec,
252
+ sizeMb,
253
+ codec,
254
+ bitrate,
255
+ };
256
+ }
257
+ catch {
258
+ return null;
259
+ }
260
+ }
261
+ function formatDuration(sec) {
262
+ if (!sec)
263
+ return "?:??";
264
+ const h = Math.floor(sec / 3600);
265
+ const m = Math.floor((sec % 3600) / 60);
266
+ const s = Math.floor(sec % 60);
267
+ if (h > 0)
268
+ return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
269
+ return `${m}:${String(s).padStart(2, "0")}`;
270
+ }
271
+ function estimateTime(durationSec, gpu) {
272
+ if (!durationSec)
273
+ return "?";
274
+ // CPU: ~1.5x realtime on Ryzen 7 2700x with medium.en
275
+ // GPU: ~0.12x realtime on Vega 56 via Vulkan with medium.en (~8x faster than CPU)
276
+ const ratio = gpu ? 0.12 : 1.5;
277
+ const estSec = Math.round(durationSec * ratio);
278
+ if (estSec < 60)
279
+ return `~${estSec}s`;
280
+ return `~${Math.round(estSec / 60)}m`;
281
+ }
282
+ function padEnd(str, len) {
283
+ return str.length >= len ? str.slice(0, len) : str + " ".repeat(len - str.length);
284
+ }
98
285
  function needsConversion(filePath) {
99
286
  return !NATIVE_EXTENSIONS.includes(extname(filePath).toLowerCase());
100
287
  }
@@ -186,7 +373,7 @@ function getFiles(dir, recursive) {
186
373
  // ---------------------------------------------------------------------------
187
374
  // MCP Server
188
375
  // ---------------------------------------------------------------------------
189
- const server = new Server({ name: "whisper-windows-mcp", version: "1.5.0" }, { capabilities: { tools: {} } });
376
+ const server = new Server({ name: "whisper-windows-mcp", version: "1.7.0" }, { capabilities: { tools: {} } });
190
377
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
191
378
  tools: [
192
379
  {
@@ -195,8 +382,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
195
382
  "Natively supports mp3 and wav. Automatically converts mp4, mkv, avi, mov, " +
196
383
  "webm, m4a, flac, ogg etc. via FFmpeg — no manual conversion needed. " +
197
384
  "Can output plain text, timestamps, JSON, or SRT subtitle files. " +
198
- "WARNING: files over 30 minutes cannot be cancelled cleanly from Claude — " +
199
- "use Task Manager to kill the process if needed.",
385
+ "For files that may take more than 4 minutes, set background=true to run as a detached job " +
386
+ "and use check_progress to monitor it.",
200
387
  inputSchema: {
201
388
  type: "object",
202
389
  properties: {
@@ -210,10 +397,24 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
210
397
  },
211
398
  threads: { type: "number", description: `CPU threads. Defaults to ${WHISPER_THREADS} of ${SYSTEM_THREADS}.` },
212
399
  save_to_file: { type: "boolean", description: "Save transcript as .txt next to the source file.", default: false },
400
+ 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 },
213
401
  },
214
402
  required: ["file_path"],
215
403
  },
216
404
  },
405
+ {
406
+ name: "check_progress",
407
+ description: "Check the status of a background transcription job started with transcribe_audio (background=true). " +
408
+ "Returns current progress, elapsed time, last processed timestamp, and the transcript when complete. " +
409
+ "Call this repeatedly until the job shows as complete or failed.",
410
+ inputSchema: {
411
+ type: "object",
412
+ properties: {
413
+ job_id: { type: "string", description: "Job ID returned by transcribe_audio when background=true." },
414
+ },
415
+ required: ["job_id"],
416
+ },
417
+ },
217
418
  {
218
419
  name: "transcribe_batch",
219
420
  description: "Transcribe multiple audio/video files in a folder interactively, one file at a time. " +
@@ -258,6 +459,29 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
258
459
  description: "Verify whisper-cli.exe, model, and FFmpeg are all available. Run this first if anything fails.",
259
460
  inputSchema: { type: "object", properties: {} },
260
461
  },
462
+ {
463
+ name: "analyze_media",
464
+ description: "Analyze one or more media files using FFprobe before transcribing. " +
465
+ "For a single file: returns duration, size, codec, and estimated transcription time on CPU and GPU. " +
466
+ "For a folder: scans all supported media files and returns a sorted table with the same info for each. " +
467
+ "Use this to plan batch work, estimate how long transcription will take, or check what's already been transcribed.",
468
+ inputSchema: {
469
+ type: "object",
470
+ properties: {
471
+ path: {
472
+ type: "string",
473
+ description: "Absolute Windows path to a single file or a folder.",
474
+ },
475
+ sort_by: {
476
+ type: "string",
477
+ enum: ["duration", "name", "size"],
478
+ description: "For folder scans: sort order. Defaults to duration (shortest first).",
479
+ default: "duration",
480
+ },
481
+ },
482
+ required: ["path"],
483
+ },
484
+ },
261
485
  {
262
486
  name: "check_system",
263
487
  description: "Detect GPU hardware and verify Vulkan acceleration is available. " +
@@ -297,6 +521,97 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
297
521
  };
298
522
  }
299
523
  // -------------------------------------------------------------------------
524
+ // analyze_media
525
+ // -------------------------------------------------------------------------
526
+ if (name === "analyze_media") {
527
+ const targetPath = args?.path;
528
+ const sortBy = args?.sort_by || "duration";
529
+ if (!targetPath)
530
+ return { content: [{ type: "text", text: "path is required." }], isError: true };
531
+ if (!existsSync(targetPath))
532
+ return { content: [{ type: "text", text: `Path not found: ${targetPath}` }], isError: true };
533
+ // Check ffprobe is available
534
+ const ffprobePath = FFMPEG_PATH.replace(/ffmpeg(\.exe)?$/i, "ffprobe$1").replace(/ffmpeg$/i, "ffprobe");
535
+ try {
536
+ await execFileAsync(ffprobePath, ["-version"], { windowsHide: true });
537
+ }
538
+ catch {
539
+ return {
540
+ content: [{ type: "text", text: "ffprobe not found. FFprobe is bundled with FFmpeg — make sure FFmpeg is installed and in your PATH." }],
541
+ isError: true,
542
+ };
543
+ }
544
+ const vulkan = hasVulkanDll();
545
+ // Single file
546
+ const stat = statSync(targetPath);
547
+ if (stat.isFile()) {
548
+ const info = await probeFile(targetPath);
549
+ if (!info) {
550
+ return { content: [{ type: "text", text: `Could not probe file: ${targetPath}\nMake sure it is a supported media file.` }], isError: true };
551
+ }
552
+ const txtPath = targetPath.replace(/\.[^.]+$/, ".txt");
553
+ const transcribed = existsSync(txtPath) ? "✅ already transcribed" : "⬜ not yet transcribed";
554
+ return {
555
+ content: [{
556
+ type: "text",
557
+ text: `📄 ${info.fileName}\n` +
558
+ `${"─".repeat(40)}\n` +
559
+ `Duration: ${formatDuration(info.durationSec)}\n` +
560
+ `Size: ${info.sizeMb.toFixed(1)} MB\n` +
561
+ `Codec: ${info.codec}\n` +
562
+ `Bitrate: ${info.bitrate} kbps\n` +
563
+ `Status: ${transcribed}\n\n` +
564
+ `Estimated transcription time:\n` +
565
+ ` CPU: ${estimateTime(info.durationSec, false)}\n` +
566
+ ` GPU: ${estimateTime(info.durationSec, true)}${vulkan ? "" : " (⚠️ Vulkan not detected — GPU estimate may not apply)"}`,
567
+ }],
568
+ };
569
+ }
570
+ // Folder scan
571
+ const files = getFiles(targetPath, false);
572
+ if (files.length === 0) {
573
+ return { content: [{ type: "text", text: `No supported media files found in: ${targetPath}` }], isError: true };
574
+ }
575
+ const results = [];
576
+ for (const f of files) {
577
+ const info = await probeFile(f);
578
+ if (info)
579
+ results.push(info);
580
+ }
581
+ // Sort
582
+ if (sortBy === "name")
583
+ results.sort((a, b) => a.fileName.localeCompare(b.fileName));
584
+ else if (sortBy === "size")
585
+ results.sort((a, b) => a.sizeMb - b.sizeMb);
586
+ else
587
+ results.sort((a, b) => a.durationSec - b.durationSec); // duration default
588
+ const totalDuration = results.reduce((acc, r) => acc + r.durationSec, 0);
589
+ const totalSize = results.reduce((acc, r) => acc + r.sizeMb, 0);
590
+ const transcribedCount = results.filter(r => existsSync(r.filePath.replace(/\.[^.]+$/, ".txt"))).length;
591
+ const header = `${padEnd("File", 36)} ${padEnd("Duration", 8)} ${padEnd("Size", 8)} ${padEnd("CPU est", 8)} ${padEnd("GPU est", 8)} Status\n` +
592
+ `${"─".repeat(90)}\n`;
593
+ const rows = results.map(r => {
594
+ const done = existsSync(r.filePath.replace(/\.[^.]+$/, ".txt")) ? "✅" : "⬜";
595
+ return (`${padEnd(r.fileName, 36)} ` +
596
+ `${padEnd(formatDuration(r.durationSec), 8)} ` +
597
+ `${padEnd(r.sizeMb.toFixed(1) + " MB", 8)} ` +
598
+ `${padEnd(estimateTime(r.durationSec, false), 8)} ` +
599
+ `${padEnd(estimateTime(r.durationSec, true), 8)} ` +
600
+ done);
601
+ }).join("\n");
602
+ const summary = `\n${"─".repeat(90)}\n` +
603
+ `${results.length} files | Total duration: ${formatDuration(totalDuration)} | Total size: ${totalSize.toFixed(1)} MB\n` +
604
+ `Transcribed: ${transcribedCount}/${results.length}\n` +
605
+ `Total est. CPU time: ${estimateTime(totalDuration, false)} | Total est. GPU time: ${estimateTime(totalDuration, true)}` +
606
+ (vulkan ? "" : "\n⚠️ ggml-vulkan.dll not detected — GPU estimates may not apply");
607
+ return {
608
+ content: [{
609
+ type: "text",
610
+ text: `Media analysis: ${targetPath}\n\n${header}${rows}${summary}`,
611
+ }],
612
+ };
613
+ }
614
+ // -------------------------------------------------------------------------
300
615
  // check_system
301
616
  // -------------------------------------------------------------------------
302
617
  if (name === "check_system") {
@@ -341,6 +656,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
341
656
  const outputFormat = (args?.output_format || "text");
342
657
  const threads = Math.min(SYSTEM_THREADS, Math.max(1, Math.round(args?.threads || WHISPER_THREADS)));
343
658
  const saveToFile = args?.save_to_file || false;
659
+ const background = args?.background || false;
344
660
  if (!filePath)
345
661
  return { content: [{ type: "text", text: "file_path is required." }], isError: true };
346
662
  if (!existsSync(filePath))
@@ -348,6 +664,33 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
348
664
  const configError = validatePaths();
349
665
  if (configError)
350
666
  return { content: [{ type: "text", text: configError }], isError: true };
667
+ // Background mode — detached process, returns immediately
668
+ if (background) {
669
+ if (await isWhisperRunning()) {
670
+ return {
671
+ content: [{ type: "text", text: "Transcription already in progress. Wait for the current job to finish before starting another." }],
672
+ isError: true,
673
+ };
674
+ }
675
+ try {
676
+ const { jobId, pid } = await spawnDetached(filePath, model, language, threads);
677
+ return {
678
+ content: [{
679
+ type: "text",
680
+ text: `⏳ Background transcription started.\n\n` +
681
+ `Source: ${basename(filePath)}\n` +
682
+ `Job ID: ${jobId}\n` +
683
+ `PID: ${pid}\n\n` +
684
+ `Call check_progress with job_id="${jobId}" to monitor progress.\n` +
685
+ `Output will be saved to: ${filePath.replace(/\.[^.]+$/, ".txt")}`,
686
+ }],
687
+ };
688
+ }
689
+ catch (err) {
690
+ return { content: [{ type: "text", text: `Failed to start background job:\n\n${err?.message || String(err)}` }], isError: true };
691
+ }
692
+ }
693
+ // Blocking mode (default)
351
694
  try {
352
695
  const result = await transcribeSingle(filePath, model, language, outputFormat, threads, saveToFile);
353
696
  let response = result.text;
@@ -362,6 +705,21 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
362
705
  }
363
706
  }
364
707
  // -------------------------------------------------------------------------
708
+ // check_progress
709
+ // -------------------------------------------------------------------------
710
+ if (name === "check_progress") {
711
+ const jobId = args?.job_id;
712
+ if (!jobId)
713
+ return { content: [{ type: "text", text: "job_id is required." }], isError: true };
714
+ try {
715
+ const result = await readJobProgress(jobId);
716
+ return { content: [{ type: "text", text: result }] };
717
+ }
718
+ catch (err) {
719
+ return { content: [{ type: "text", text: `Error reading job progress:\n\n${err?.message || String(err)}` }], isError: true };
720
+ }
721
+ }
722
+ // -------------------------------------------------------------------------
365
723
  // generate_subtitles
366
724
  // -------------------------------------------------------------------------
367
725
  if (name === "generate_subtitles") {
@@ -476,7 +834,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
476
834
  async function main() {
477
835
  const transport = new StdioServerTransport();
478
836
  await server.connect(transport);
479
- console.error(`whisper-windows-mcp v1.5.0 running | threads: ${WHISPER_THREADS}/${SYSTEM_THREADS}`);
837
+ console.error(`whisper-windows-mcp v1.7.0 running | threads: ${WHISPER_THREADS}/${SYSTEM_THREADS}`);
480
838
  }
481
839
  main().catch((err) => {
482
840
  console.error("Fatal error:", err);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whisper-windows-mcp",
3
- "version": "1.5.1",
3
+ "version": "1.7.0",
4
4
  "description": "Windows-native MCP server for local audio transcription using whisper.cpp with Vulkan GPU acceleration",
5
5
  "main": "dist/index.js",
6
6
  "bin": {