whisper-windows-mcp 1.7.0 → 1.8.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 +232 -2
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -186,6 +186,123 @@ async function readJobProgress(jobId) {
186
186
  `${progressLine}\n\n` +
187
187
  `Call check_progress with this job ID to get an update.`);
188
188
  }
189
+ function validateTranscript(txtPath, durationSec) {
190
+ if (!existsSync(txtPath))
191
+ return { valid: false, reason: "output file missing" };
192
+ const content = readFileSync(txtPath, "utf8").trim();
193
+ if (!content)
194
+ return { valid: false, reason: "output file is empty" };
195
+ const lines = content.split(/\n/).filter(l => l.trim()).length;
196
+ const minExpected = Math.max(1, Math.floor(durationSec / 30));
197
+ if (lines < minExpected) {
198
+ return { valid: false, reason: `only ${lines} line(s) for ${Math.round(durationSec)}s audio (expected ≥${minExpected})` };
199
+ }
200
+ return { valid: true };
201
+ }
202
+ async function spawnNextBatchJob(state) {
203
+ for (let i = state.currentIndex; i < state.files.length; i++) {
204
+ if (state.files[i].status === "pending") {
205
+ state.currentIndex = i;
206
+ state.files[i].status = "running";
207
+ const f = state.files[i];
208
+ const { jobId } = await spawnDetached(f.filePath, state.model, state.language, state.threads);
209
+ state.files[i].jobId = jobId;
210
+ writeFileSync(state.batchPath, JSON.stringify(state, null, 2), "utf8");
211
+ return;
212
+ }
213
+ }
214
+ // Nothing left to run
215
+ state.status = "complete";
216
+ writeFileSync(state.batchPath, JSON.stringify(state, null, 2), "utf8");
217
+ }
218
+ async function readBatchProgress(batchId) {
219
+ const batchPath = join(JOBS_DIR, `${batchId}.batch.json`);
220
+ if (!existsSync(batchPath)) {
221
+ return `❌ Batch not found: ${batchId}\n\nThe batch file may have been deleted or the ID is incorrect.`;
222
+ }
223
+ const state = JSON.parse(readFileSync(batchPath, "utf8"));
224
+ // Check current running job
225
+ const running = state.files.find(f => f.status === "running");
226
+ if (running && running.jobId) {
227
+ const jobPath = join(JOBS_DIR, `${running.jobId}.json`);
228
+ if (existsSync(jobPath)) {
229
+ const job = JSON.parse(readFileSync(jobPath, "utf8"));
230
+ const isRunning = await isPidRunning(job.pid);
231
+ const outputExists = existsSync(job.outputPath);
232
+ if (!isRunning) {
233
+ // Job finished — validate and advance
234
+ const validation = validateTranscript(job.outputPath, running.durationSec);
235
+ if (outputExists && validation.valid) {
236
+ running.status = "complete";
237
+ }
238
+ else {
239
+ running.status = "failed";
240
+ running.failReason = validation.reason ?? "no output file";
241
+ }
242
+ // Advance to next
243
+ state.currentIndex = state.files.indexOf(running) + 1;
244
+ if (state.files.some(f => f.status === "pending")) {
245
+ await spawnNextBatchJob(state);
246
+ }
247
+ else {
248
+ state.status = "complete";
249
+ writeFileSync(batchPath, JSON.stringify(state, null, 2), "utf8");
250
+ }
251
+ }
252
+ else {
253
+ // Still running — update state file without advancing
254
+ writeFileSync(batchPath, JSON.stringify(state, null, 2), "utf8");
255
+ }
256
+ }
257
+ }
258
+ else if (state.status !== "complete" && state.files.some(f => f.status === "pending")) {
259
+ // No running job but pending files exist — advance
260
+ await spawnNextBatchJob(state);
261
+ }
262
+ // Build status report
263
+ const done = state.files.filter(f => f.status === "complete").length;
264
+ const failed = state.files.filter(f => f.status === "failed");
265
+ const pending = state.files.filter(f => f.status === "pending").length;
266
+ const currentRunning = state.files.find(f => f.status === "running");
267
+ const total = state.files.length;
268
+ const elapsed = Math.round((Date.now() - new Date(state.startTime).getTime()) / 1000);
269
+ let report = `Batch: ${batchId}\n`;
270
+ report += `Folder: ${state.folder}\n`;
271
+ report += `${"─".repeat(50)}\n`;
272
+ report += `Progress: ${done}/${total} complete`;
273
+ if (failed.length > 0)
274
+ report += ` | ${failed.length} failed`;
275
+ if (pending > 0)
276
+ report += ` | ${pending} remaining`;
277
+ report += `\nElapsed: ${formatDuration(elapsed)}\n`;
278
+ if (currentRunning) {
279
+ report += `\nCurrently processing: ${currentRunning.fileName}`;
280
+ if (currentRunning.jobId) {
281
+ const jobPath = join(JOBS_DIR, `${currentRunning.jobId}.json`);
282
+ if (existsSync(jobPath)) {
283
+ const job = JSON.parse(readFileSync(jobPath, "utf8"));
284
+ const logContent = existsSync(job.logPath) ? readFileSync(job.logPath, "utf8") : "";
285
+ const lastSec = parseLastTimestamp(logContent);
286
+ if (lastSec > 0)
287
+ report += ` (${formatDuration(lastSec)} / ${formatDuration(currentRunning.durationSec)})`;
288
+ }
289
+ }
290
+ }
291
+ if (failed.length > 0) {
292
+ report += `\n\n⚠️ Failed files:\n`;
293
+ for (const f of failed) {
294
+ report += ` ❌ ${f.fileName} — ${f.failReason ?? "unknown reason"}\n`;
295
+ }
296
+ report += `\nRe-run failed files with transcribe_audio individually.`;
297
+ }
298
+ if (state.status === "complete") {
299
+ report = `✅ Batch complete!\n\n` + report;
300
+ }
301
+ else {
302
+ report += `\n\nCall check_batch_progress again to update.`;
303
+ }
304
+ return report;
305
+ }
189
306
  async function detectGpus() {
190
307
  try {
191
308
  const { stdout } = await execFileAsync("wmic", ["path", "win32_VideoController", "get", "name,AdapterRAM", "/format:csv"], { windowsHide: true });
@@ -373,7 +490,7 @@ function getFiles(dir, recursive) {
373
490
  // ---------------------------------------------------------------------------
374
491
  // MCP Server
375
492
  // ---------------------------------------------------------------------------
376
- const server = new Server({ name: "whisper-windows-mcp", version: "1.7.0" }, { capabilities: { tools: {} } });
493
+ const server = new Server({ name: "whisper-windows-mcp", version: "1.8.0" }, { capabilities: { tools: {} } });
377
494
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
378
495
  tools: [
379
496
  {
@@ -459,6 +576,37 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
459
576
  description: "Verify whisper-cli.exe, model, and FFmpeg are all available. Run this first if anything fails.",
460
577
  inputSchema: { type: "object", properties: {} },
461
578
  },
579
+ {
580
+ name: "start_batch",
581
+ description: "Start an automated sequential batch transcription of all untranscribed files in a folder. " +
582
+ "Scans for files without a matching .txt, sorts by duration (shortest first), " +
583
+ "and processes them one at a time as background jobs. " +
584
+ "Each file is validated after completion — empty or suspiciously short outputs are flagged. " +
585
+ "Returns a batch ID to use with check_batch_progress.",
586
+ inputSchema: {
587
+ type: "object",
588
+ properties: {
589
+ folder_path: { type: "string", description: "Absolute Windows path to the folder." },
590
+ language: { type: "string", description: "Language code. Defaults to en.", default: "en" },
591
+ threads: { type: "number", description: `CPU threads. Defaults to ${WHISPER_THREADS} of ${SYSTEM_THREADS}.` },
592
+ },
593
+ required: ["folder_path"],
594
+ },
595
+ },
596
+ {
597
+ name: "check_batch_progress",
598
+ description: "Check the status of a batch started with start_batch. " +
599
+ "Automatically advances to the next file when the current one finishes. " +
600
+ "Returns overall progress, current file, failed files, and elapsed time. " +
601
+ "Call repeatedly until the batch shows as complete.",
602
+ inputSchema: {
603
+ type: "object",
604
+ properties: {
605
+ batch_id: { type: "string", description: "Batch ID returned by start_batch." },
606
+ },
607
+ required: ["batch_id"],
608
+ },
609
+ },
462
610
  {
463
611
  name: "analyze_media",
464
612
  description: "Analyze one or more media files using FFprobe before transcribing. " +
@@ -720,6 +868,88 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
720
868
  }
721
869
  }
722
870
  // -------------------------------------------------------------------------
871
+ // start_batch
872
+ // -------------------------------------------------------------------------
873
+ if (name === "start_batch") {
874
+ const folderPath = args?.folder_path;
875
+ const language = args?.language || "en";
876
+ const threads = Math.min(SYSTEM_THREADS, Math.max(1, Math.round(args?.threads || WHISPER_THREADS)));
877
+ if (!folderPath)
878
+ return { content: [{ type: "text", text: "folder_path is required." }], isError: true };
879
+ if (!existsSync(folderPath))
880
+ return { content: [{ type: "text", text: `Folder not found: ${folderPath}` }], isError: true };
881
+ const configError = validatePaths();
882
+ if (configError)
883
+ return { content: [{ type: "text", text: configError }], isError: true };
884
+ if (await isWhisperRunning()) {
885
+ return { content: [{ type: "text", text: "A transcription is already running. Wait for it to finish before starting a batch." }], isError: true };
886
+ }
887
+ // Scan for untranscribed files
888
+ const allFiles = getFiles(folderPath, false);
889
+ const untranscribed = allFiles.filter(f => !existsSync(f.replace(/\.[^.]+$/, ".txt")));
890
+ if (untranscribed.length === 0) {
891
+ return { content: [{ type: "text", text: `✅ All files in ${folderPath} are already transcribed. Nothing to do.` }] };
892
+ }
893
+ // Probe durations for sorting
894
+ const batchFiles = [];
895
+ for (const f of untranscribed) {
896
+ const info = await probeFile(f);
897
+ batchFiles.push({
898
+ filePath: f,
899
+ fileName: basename(f),
900
+ durationSec: info?.durationSec ?? 0,
901
+ status: "pending",
902
+ });
903
+ }
904
+ batchFiles.sort((a, b) => a.durationSec - b.durationSec);
905
+ ensureJobsDir();
906
+ const batchId = `batch_${Date.now()}`;
907
+ const batchPath = join(JOBS_DIR, `${batchId}.batch.json`);
908
+ const state = {
909
+ batchId,
910
+ batchPath,
911
+ folder: folderPath,
912
+ startTime: new Date().toISOString(),
913
+ files: batchFiles,
914
+ currentIndex: 0,
915
+ status: "running",
916
+ model: WHISPER_MODEL,
917
+ language,
918
+ threads,
919
+ };
920
+ writeFileSync(batchPath, JSON.stringify(state, null, 2), "utf8");
921
+ await spawnNextBatchJob(state);
922
+ const totalDuration = batchFiles.reduce((acc, f) => acc + f.durationSec, 0);
923
+ return {
924
+ content: [{
925
+ type: "text",
926
+ text: `⏳ Batch started!\n\n` +
927
+ `Batch ID: ${batchId}\n` +
928
+ `Folder: ${folderPath}\n` +
929
+ `Files to process: ${batchFiles.length}\n` +
930
+ `Total audio: ${formatDuration(totalDuration)}\n` +
931
+ `Est. GPU time: ${estimateTime(totalDuration, hasVulkanDll())}\n\n` +
932
+ `First file: ${batchFiles[0].fileName}\n\n` +
933
+ `Call check_batch_progress with batch_id="${batchId}" to monitor.`,
934
+ }],
935
+ };
936
+ }
937
+ // -------------------------------------------------------------------------
938
+ // check_batch_progress
939
+ // -------------------------------------------------------------------------
940
+ if (name === "check_batch_progress") {
941
+ const batchId = args?.batch_id;
942
+ if (!batchId)
943
+ return { content: [{ type: "text", text: "batch_id is required." }], isError: true };
944
+ try {
945
+ const result = await readBatchProgress(batchId);
946
+ return { content: [{ type: "text", text: result }] };
947
+ }
948
+ catch (err) {
949
+ return { content: [{ type: "text", text: `Error reading batch progress:\n\n${err?.message || String(err)}` }], isError: true };
950
+ }
951
+ }
952
+ // -------------------------------------------------------------------------
723
953
  // generate_subtitles
724
954
  // -------------------------------------------------------------------------
725
955
  if (name === "generate_subtitles") {
@@ -834,7 +1064,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
834
1064
  async function main() {
835
1065
  const transport = new StdioServerTransport();
836
1066
  await server.connect(transport);
837
- console.error(`whisper-windows-mcp v1.7.0 running | threads: ${WHISPER_THREADS}/${SYSTEM_THREADS}`);
1067
+ console.error(`whisper-windows-mcp v1.8.0 running | threads: ${WHISPER_THREADS}/${SYSTEM_THREADS}`);
838
1068
  }
839
1069
  main().catch((err) => {
840
1070
  console.error("Fatal error:", err);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whisper-windows-mcp",
3
- "version": "1.7.0",
3
+ "version": "1.8.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": {