whisper-windows-mcp 1.6.0 → 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 +196 -7
  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 });
@@ -240,7 +373,7 @@ function getFiles(dir, recursive) {
240
373
  // ---------------------------------------------------------------------------
241
374
  // MCP Server
242
375
  // ---------------------------------------------------------------------------
243
- const server = new Server({ name: "whisper-windows-mcp", version: "1.6.0" }, { capabilities: { tools: {} } });
376
+ const server = new Server({ name: "whisper-windows-mcp", version: "1.7.0" }, { capabilities: { tools: {} } });
244
377
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
245
378
  tools: [
246
379
  {
@@ -249,8 +382,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
249
382
  "Natively supports mp3 and wav. Automatically converts mp4, mkv, avi, mov, " +
250
383
  "webm, m4a, flac, ogg etc. via FFmpeg — no manual conversion needed. " +
251
384
  "Can output plain text, timestamps, JSON, or SRT subtitle files. " +
252
- "WARNING: files over 30 minutes cannot be cancelled cleanly from Claude — " +
253
- "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.",
254
387
  inputSchema: {
255
388
  type: "object",
256
389
  properties: {
@@ -264,10 +397,24 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
264
397
  },
265
398
  threads: { type: "number", description: `CPU threads. Defaults to ${WHISPER_THREADS} of ${SYSTEM_THREADS}.` },
266
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 },
267
401
  },
268
402
  required: ["file_path"],
269
403
  },
270
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
+ },
271
418
  {
272
419
  name: "transcribe_batch",
273
420
  description: "Transcribe multiple audio/video files in a folder interactively, one file at a time. " +
@@ -396,7 +543,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
396
543
  }
397
544
  const vulkan = hasVulkanDll();
398
545
  // Single file
399
- const { statSync } = await import("fs");
400
546
  const stat = statSync(targetPath);
401
547
  if (stat.isFile()) {
402
548
  const info = await probeFile(targetPath);
@@ -510,6 +656,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
510
656
  const outputFormat = (args?.output_format || "text");
511
657
  const threads = Math.min(SYSTEM_THREADS, Math.max(1, Math.round(args?.threads || WHISPER_THREADS)));
512
658
  const saveToFile = args?.save_to_file || false;
659
+ const background = args?.background || false;
513
660
  if (!filePath)
514
661
  return { content: [{ type: "text", text: "file_path is required." }], isError: true };
515
662
  if (!existsSync(filePath))
@@ -517,6 +664,33 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
517
664
  const configError = validatePaths();
518
665
  if (configError)
519
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)
520
694
  try {
521
695
  const result = await transcribeSingle(filePath, model, language, outputFormat, threads, saveToFile);
522
696
  let response = result.text;
@@ -531,6 +705,21 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
531
705
  }
532
706
  }
533
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
+ // -------------------------------------------------------------------------
534
723
  // generate_subtitles
535
724
  // -------------------------------------------------------------------------
536
725
  if (name === "generate_subtitles") {
@@ -645,7 +834,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
645
834
  async function main() {
646
835
  const transport = new StdioServerTransport();
647
836
  await server.connect(transport);
648
- console.error(`whisper-windows-mcp v1.6.0 running | threads: ${WHISPER_THREADS}/${SYSTEM_THREADS}`);
837
+ console.error(`whisper-windows-mcp v1.7.0 running | threads: ${WHISPER_THREADS}/${SYSTEM_THREADS}`);
649
838
  }
650
839
  main().catch((err) => {
651
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.6.0",
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": {