diff --git a/CLAUDE.md b/CLAUDE.md index 90243ae..05e245e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -154,12 +154,18 @@ broken on some Windows 11 builds (stuck in `playState 9`). ### Local Whisper (whisper.cpp) -The local STT provider uses whisper.cpp via the `nodejs-whisper` package: +The local STT provider uses whisper.cpp via the `nodejs-whisper` package. +Binary provisioning order in `ensureWhisperBinary()`: +- **All platforms:** A `whisper-cli` already on PATH (e.g. `brew install + whisper-cpp` on macOS) is adopted automatically — copied into the + nodejs-whisper tree, no compile needed. - **Windows:** Prebuilt `whisper-cli` binary is auto-downloaded from GitHub releases (v1.8.3). -- **macOS/Linux:** Built from source via CMake. On macOS, Xcode Command Line - Tools alone are not enough — CMake must be installed separately +- **macOS/Linux fallback:** Built from source via CMake. On macOS, Xcode + Command Line Tools alone are not enough — CMake must be installed separately (`brew install cmake`). - **All platforms:** Requires FFmpeg on PATH for audio conversion. +- `devglide setup` ends with a voice dependency doctor (`bin/doctor.js`) + reporting ffmpeg / whisper-cli / cmake status with install hints. - **Bundling:** `nodejs-whisper` must stay in the esbuild `external` list in `scripts/build-mcp.mjs` (and in root `package.json` dependencies so the bundle can resolve it). Inlining it breaks `__dirname` resolution and the diff --git a/bin/claude-md-template.js b/bin/claude-md-template.js index 658a831..8dc4dd2 100644 --- a/bin/claude-md-template.js +++ b/bin/claude-md-template.js @@ -81,7 +81,7 @@ Describe what to test in natural language and scenarios are generated automatica - \`voice_analytics\` — get aggregated transcription analytics - \`voice_status\` — check transcription service status and statistics - **STT providers:** openai, groq, local (whisper.cpp), whisper-cpp, faster-whisper, vllm, local-ai -- **Local whisper:** On Windows, prebuilt whisper-cli is auto-downloaded from GitHub releases. On macOS/Linux, built from source via CMake. +- **Local whisper:** A whisper-cli already on PATH (e.g. `brew install whisper-cpp`) is adopted automatically. On Windows, prebuilt whisper-cli is auto-downloaded from GitHub releases. Otherwise built from source via CMake. - **TTS engine:** msedge-tts (Microsoft Edge Read Aloud). Long text is automatically split into sentence chunks with pipelined generation + playback. - **TTS config:** \`voice\`, \`edgeRate\`, \`edgePitch\`, \`volume\`, \`chunkThreshold\`, \`fallbackRate\`, \`enabled\` — configurable via dashboard. - **TTS fallback chain:** If msedge-tts fails → PowerShell SAPI (Windows/WSL), \`say\` (macOS), espeak-ng/spd-say (Linux). diff --git a/bin/devglide.js b/bin/devglide.js index b3ecbfd..8a33ff4 100755 --- a/bin/devglide.js +++ b/bin/devglide.js @@ -9,6 +9,7 @@ import { homedir } from "os"; import { getClaudeMdContent, injectSection, removeSection } from "./claude-md-template.js"; import { removeDevglideSectionsFromToml } from "./codex-config.js"; +import { runDoctor } from "./doctor.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const root = resolve(__dirname, ".."); @@ -582,6 +583,9 @@ function runSetup() { } } + // Report voice/local-whisper environment dependencies (non-fatal) + runDoctor(); + console.log("\n Setup complete!\n"); } diff --git a/bin/doctor.js b/bin/doctor.js new file mode 100644 index 0000000..b8b2842 --- /dev/null +++ b/bin/doctor.js @@ -0,0 +1,122 @@ +// Dependency doctor for the voice local-whisper provider. +// Reports whether local transcription can work on this machine: ffmpeg +// (always required), whisper-cli (built in the nodejs-whisper tree, on PATH, +// or auto-provisioned), and cmake (only needed for a from-source build). + +import { execSync } from "child_process"; +import { existsSync } from "fs"; +import { join, dirname } from "path"; +import { createRequire } from "module"; + +/** Run a probe command; return its first output line, or null if it fails. */ +export function defaultProbe(cmd) { + try { + const out = execSync(cmd, { stdio: "pipe", timeout: 10_000 }).toString(); + return out.split(/\r?\n/).map((line) => line.trim()).filter(Boolean)[0] ?? "found"; + } catch { + return null; + } +} + +/** Check whether whisper-cli was already built inside the nodejs-whisper package. */ +function whisperCliBuiltInTree() { + try { + const require_ = createRequire(import.meta.url); + const whisperCppPath = join( + dirname(require_.resolve("nodejs-whisper/package.json")), + "cpp", + "whisper.cpp" + ); + const exeName = process.platform === "win32" ? "whisper-cli.exe" : "whisper-cli"; + return [ + join(whisperCppPath, "build", "bin", exeName), + join(whisperCppPath, "build", "bin", "Release", exeName), + join(whisperCppPath, "build", "bin", "Debug", exeName), + join(whisperCppPath, "build", exeName), + join(whisperCppPath, exeName), + ].some((p) => existsSync(p)); + } catch { + return false; + } +} + +/** + * Collect dependency checks as { name, ok, detail, hint } entries. + * All environment access is injectable for tests. + */ +export function collectDoctorChecks({ + platform = process.platform, + probe = defaultProbe, + builtInTree = whisperCliBuiltInTree, +} = {}) { + const checks = []; + + const ffmpeg = probe("ffmpeg -version"); + checks.push({ + name: "ffmpeg", + ok: ffmpeg !== null, + detail: ffmpeg ?? "not found on PATH", + hint: ffmpeg !== null + ? null + : platform === "darwin" + ? "brew install ffmpeg" + : platform === "win32" + ? "winget install ffmpeg" + : "sudo apt install ffmpeg", + }); + + const inTree = builtInTree(); + const onPath = probe(platform === "win32" ? "where whisper-cli" : "command -v whisper-cli"); + // Windows never needs manual provisioning: a prebuilt binary is downloaded + // on first transcription. + const whisperOk = inTree || onPath !== null || platform === "win32"; + checks.push({ + name: "whisper-cli", + ok: whisperOk, + detail: inTree + ? "already built for nodejs-whisper" + : onPath !== null + ? `found on PATH (${onPath}) — adopted automatically on first use` + : platform === "win32" + ? "not found — prebuilt binary is auto-downloaded on first transcription" + : "not found", + hint: whisperOk + ? null + : platform === "darwin" + ? "brew install whisper-cpp (no compile needed — adopted automatically)" + : "install build tools below; whisper.cpp is compiled on first transcription", + }); + + const cmakeNeeded = !inTree && onPath === null && platform !== "win32"; + const cmake = probe("cmake --version"); + const cmakeOk = cmake !== null || !cmakeNeeded; + checks.push({ + name: "cmake", + ok: cmakeOk, + detail: cmake ?? (cmakeNeeded ? "not found on PATH" : "not needed (whisper-cli already available)"), + hint: cmakeOk + ? null + : platform === "darwin" + ? "brew install cmake (xcode-select --install alone is not enough)" + : "sudo apt install build-essential cmake", + }); + + return checks; +} + +/** Render checks as an indented report block. */ +export function formatDoctorReport(checks) { + const lines = ["", " Voice dependency check:", ""]; + for (const check of checks) { + lines.push(` ${check.ok ? "✓" : "✗"} ${check.name}: ${check.detail}`); + if (check.hint) lines.push(` → ${check.hint}`); + } + return lines.join("\n"); +} + +/** Print the doctor report; returns true when every check passed. */ +export function runDoctor(options = {}) { + const checks = collectDoctorChecks(options); + console.log(formatDoctorReport(checks)); + return checks.every((check) => check.ok); +} diff --git a/bin/doctor.test.ts b/bin/doctor.test.ts new file mode 100644 index 0000000..3f9046a --- /dev/null +++ b/bin/doctor.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; +import { collectDoctorChecks, formatDoctorReport } from './doctor.js'; + +type Check = { name: string; ok: boolean; detail: string; hint: string | null }; + +function byName(checks: Check[], name: string): Check { + const check = checks.find((c) => c.name === name); + if (!check) throw new Error(`missing check: ${name}`); + return check; +} + +// The doctor reports whether the voice local-whisper provider can work: +// ffmpeg (always required), whisper-cli (built, on PATH, or auto-provisioned), +// and cmake (only needed when whisper-cli must be compiled from source). +describe('collectDoctorChecks', () => { + it('reports all green when everything is available', () => { + const checks = collectDoctorChecks({ + platform: 'darwin', + probe: (cmd: string) => + cmd.startsWith('ffmpeg') ? 'ffmpeg version 7.1' : + cmd.startsWith('cmake') ? 'cmake version 3.30' : + '/opt/homebrew/bin/whisper-cli', + builtInTree: () => false, + }); + + expect(checks.every((c) => c.ok)).toBe(true); + expect(byName(checks, 'whisper-cli').detail).toContain('adopted automatically'); + }); + + it('darwin with nothing installed: fails with brew hints', () => { + const checks = collectDoctorChecks({ + platform: 'darwin', + probe: () => null, + builtInTree: () => false, + }); + + expect(byName(checks, 'ffmpeg').ok).toBe(false); + expect(byName(checks, 'ffmpeg').hint).toContain('brew install ffmpeg'); + expect(byName(checks, 'whisper-cli').ok).toBe(false); + expect(byName(checks, 'whisper-cli').hint).toContain('brew install whisper-cpp'); + expect(byName(checks, 'cmake').ok).toBe(false); + expect(byName(checks, 'cmake').hint).toContain('brew install cmake'); + }); + + it('win32 without whisper-cli: still ok — prebuilt is auto-downloaded', () => { + const checks = collectDoctorChecks({ + platform: 'win32', + probe: (cmd: string) => (cmd.startsWith('ffmpeg') ? 'ffmpeg version 7.1' : null), + builtInTree: () => false, + }); + + expect(byName(checks, 'whisper-cli').ok).toBe(true); + expect(byName(checks, 'whisper-cli').detail).toContain('auto-downloaded'); + expect(byName(checks, 'cmake').ok).toBe(true); + }); + + it('cmake is not required once whisper-cli is already built', () => { + const checks = collectDoctorChecks({ + platform: 'linux', + probe: (cmd: string) => (cmd.startsWith('ffmpeg') ? 'ffmpeg version 7.1' : null), + builtInTree: () => true, + }); + + expect(byName(checks, 'whisper-cli').ok).toBe(true); + expect(byName(checks, 'cmake').ok).toBe(true); + expect(byName(checks, 'cmake').detail).toContain('not needed'); + }); +}); + +describe('formatDoctorReport', () => { + it('renders pass/fail marks and hints', () => { + const report = formatDoctorReport([ + { name: 'ffmpeg', ok: true, detail: 'ffmpeg version 7.1', hint: null }, + { name: 'whisper-cli', ok: false, detail: 'not found', hint: 'brew install whisper-cpp' }, + ]); + + expect(report).toContain('✓ ffmpeg: ffmpeg version 7.1'); + expect(report).toContain('✗ whisper-cli: not found'); + expect(report).toContain('→ brew install whisper-cpp'); + }); +}); diff --git a/package.json b/package.json index 075f8e8..5fadc85 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "devglide", - "version": "0.2.37", + "version": "0.2.38", "description": "AI workflow toolkit with MCP servers for kanban, shell, testing, workflows, and more — built for Claude Code", "license": "MIT", "author": "Daniel Kutyla", diff --git a/src/apps/voice/src/mcp.ts b/src/apps/voice/src/mcp.ts index a2255ca..bd1ec5a 100644 --- a/src/apps/voice/src/mcp.ts +++ b/src/apps/voice/src/mcp.ts @@ -22,7 +22,7 @@ export function createVoiceMcpServer() { "- Supports vocabulary biasing via `prompt` parameter to improve accuracy for developer terminology.", "- Two modes: `raw` (default) returns transcription as-is, `cleanup` applies AI post-processing to remove filler words and fix grammar.", "- Providers: openai, groq, local (whisper.cpp), whisper-cpp, faster-whisper, vllm, local-ai.", - "- Local whisper: on Windows prebuilt binary is auto-downloaded; on macOS/Linux built from source via CMake.", + "- Local whisper: whisper-cli on PATH (e.g. brew install whisper-cpp) is adopted automatically; on Windows prebuilt binary is auto-downloaded; otherwise built from source via CMake.", "", "### Text-to-speech (TTS)", "- Use `voice_speak` to speak text aloud. Fire-and-forget — cancels any previous speech.", diff --git a/src/apps/voice/src/providers/local-whisper.adopt.test.ts b/src/apps/voice/src/providers/local-whisper.adopt.test.ts new file mode 100644 index 0000000..1addced --- /dev/null +++ b/src/apps/voice/src/providers/local-whisper.adopt.test.ts @@ -0,0 +1,52 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { adoptSystemWhisperCli } from './local-whisper.js'; + +// A whisper-cli installed on PATH (e.g. `brew install whisper-cpp`) can be +// adopted by copying it into the location nodejs-whisper expects, skipping +// the CMake source build entirely. +describe('adoptSystemWhisperCli', () => { + const exeName = process.platform === 'win32' ? 'whisper-cli.exe' : 'whisper-cli'; + let whisperCppDir: string; + let fixtureDir: string; + + beforeEach(() => { + whisperCppDir = mkdtempSync(join(tmpdir(), 'devglide-whisper-cpp-')); + fixtureDir = mkdtempSync(join(tmpdir(), 'devglide-system-cli-')); + }); + + afterEach(() => { + rmSync(whisperCppDir, { recursive: true, force: true }); + rmSync(fixtureDir, { recursive: true, force: true }); + }); + + it('copies a system binary into build/bin and reports success', () => { + const systemCli = join(fixtureDir, exeName); + writeFileSync(systemCli, 'fake-whisper-binary'); + + const adopted = adoptSystemWhisperCli(whisperCppDir, () => systemCli); + + expect(adopted).toBe(true); + const target = join(whisperCppDir, 'build', 'bin', exeName); + expect(existsSync(target)).toBe(true); + expect(readFileSync(target, 'utf8')).toBe('fake-whisper-binary'); + }); + + it('returns false when no system binary is found', () => { + const adopted = adoptSystemWhisperCli(whisperCppDir, () => null); + + expect(adopted).toBe(false); + expect(existsSync(join(whisperCppDir, 'build'))).toBe(false); + }); + + it('returns false when the copy fails', () => { + const adopted = adoptSystemWhisperCli( + whisperCppDir, + () => join(fixtureDir, 'does-not-exist', exeName) + ); + + expect(adopted).toBe(false); + }); +}); diff --git a/src/apps/voice/src/providers/local-whisper.hint.test.ts b/src/apps/voice/src/providers/local-whisper.hint.test.ts new file mode 100644 index 0000000..8e331df --- /dev/null +++ b/src/apps/voice/src/providers/local-whisper.hint.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { buildToolsHint } from './local-whisper.js'; + +// The provisioning-failure hint must describe what actually happened on the +// current platform. Prebuilt binaries only exist for Windows — claiming a +// download was attempted on macOS/Linux sends users down the wrong path. +describe('buildToolsHint', () => { + it('darwin: explains source build failed, never claims a prebuilt download', () => { + const hint = buildToolsHint('darwin'); + expect(hint).not.toContain('Prebuilt binary download'); + expect(hint).toContain('from source'); + expect(hint).toContain('brew install cmake'); + expect(hint).not.toContain('winget'); + }); + + it('darwin: offers brew whisper-cpp as the no-compile path', () => { + const hint = buildToolsHint('darwin'); + expect(hint).toContain('brew install whisper-cpp'); + }); + + it('win32: mentions the prebuilt download attempt and Windows build tools', () => { + const hint = buildToolsHint('win32'); + expect(hint).toContain('Prebuilt binary download'); + expect(hint).toContain('winget install Kitware.CMake'); + expect(hint).not.toContain('brew install'); + }); + + it('linux: explains source build failed with apt instructions', () => { + const hint = buildToolsHint('linux'); + expect(hint).not.toContain('Prebuilt binary download'); + expect(hint).toContain('build-essential'); + }); + + it('always includes the manual compile steps', () => { + for (const platform of ['darwin', 'win32', 'linux'] as const) { + const hint = buildToolsHint(platform); + expect(hint).toContain('cd node_modules/nodejs-whisper/cpp/whisper.cpp'); + expect(hint).toContain('cmake -B build'); + expect(hint).toContain('cmake --build build --config Release'); + } + }); +}); diff --git a/src/apps/voice/src/providers/local-whisper.ts b/src/apps/voice/src/providers/local-whisper.ts index aaf3ffc..d5d6cb3 100644 --- a/src/apps/voice/src/providers/local-whisper.ts +++ b/src/apps/voice/src/providers/local-whisper.ts @@ -1,4 +1,4 @@ -import { writeFileSync, readFileSync, unlinkSync, mkdirSync, existsSync, createWriteStream } from "fs"; +import { writeFileSync, readFileSync, unlinkSync, mkdirSync, existsSync, createWriteStream, copyFileSync } from "fs"; import { Readable } from "stream"; import { pipeline } from "stream/promises"; import { join, resolve, dirname } from "path"; @@ -66,25 +66,46 @@ const FFMPEG_INSTALL_HINT = " macOS: brew install ffmpeg\n" + " Linux: sudo apt install ffmpeg (or your distro's package manager)"; -const BUILD_TOOLS_HINT = - "The local whisper provider uses nodejs-whisper which needs whisper.cpp.\n" + - "Prebuilt binary download was attempted but failed.\n" + - "\n" + - "To compile manually:\n" + - "\n" + - "Step 1 — Install build tools (if not already installed):\n" + - " Windows: winget install Kitware.CMake\n" + - " winget install Microsoft.VisualStudio.2022.BuildTools --override \"--add Microsoft.VisualStudio.Workload.VCTools\"\n" + - " macOS: xcode-select --install\n" + - " brew install cmake (Command Line Tools do NOT include CMake)\n" + - " Linux: sudo apt install build-essential cmake\n" + - "\n" + - "Step 2 — Compile whisper.cpp (from project root):\n" + - " cd node_modules/nodejs-whisper/cpp/whisper.cpp\n" + - " cmake -B build\n" + - " cmake --build build --config Release\n" + - "\n" + - "Then restart the server."; +/** + * Platform-aware guidance for when the whisper-cli binary could not be + * provisioned. Prebuilt binaries only exist for Windows — on macOS/Linux the + * only automatic path is a CMake source build, so the hint must not claim a + * download was attempted there. + */ +export function buildToolsHint(platform: NodeJS.Platform = process.platform): string { + const attempted = + platform === "win32" + ? "Prebuilt binary download was attempted but failed." + : "Building whisper.cpp from source was attempted but failed (no prebuilt binaries are published for this platform)."; + + const installTools = + platform === "win32" + ? " Windows: winget install Kitware.CMake\n" + + " winget install Microsoft.VisualStudio.2022.BuildTools --override \"--add Microsoft.VisualStudio.Workload.VCTools\"\n" + : platform === "darwin" + ? " macOS (no compile needed): brew install whisper-cpp (then retry — the binary is adopted automatically)\n" + + " macOS (compile from source):\n" + + " xcode-select --install\n" + + " brew install cmake (Command Line Tools do NOT include CMake)\n" + : " Linux: sudo apt install build-essential cmake\n"; + + return ( + "The local whisper provider uses nodejs-whisper which needs whisper.cpp.\n" + + `${attempted}\n` + + "\n" + + "To compile manually:\n" + + "\n" + + "Step 1 — Install build tools (if not already installed):\n" + + installTools + + "\n" + + "Step 2 — Compile whisper.cpp (from project root):\n" + + " cd node_modules/nodejs-whisper/cpp/whisper.cpp\n" + + " cmake -B build\n" + + " cmake --build build --config Release\n" + + "\n" + + "Then restart the server." + ); +} // --- Prebuilt whisper.cpp binary download --- @@ -189,6 +210,47 @@ function whisperCliExists(whisperCppPath: string): boolean { return candidates.some((p) => existsSync(p)); } +/** Find a whisper-cli binary on PATH (e.g. installed via `brew install whisper-cpp`). */ +function findSystemWhisperCli(): string | null { + const lookup = process.platform === "win32" ? "where whisper-cli" : "command -v whisper-cli"; + try { + const first = execSync(lookup, { stdio: "pipe", timeout: 10_000 }) + .toString() + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean)[0]; + return first && existsSync(first) ? first : null; + } catch { + return null; + } +} + +/** + * Copy a system-installed whisper-cli into the location nodejs-whisper + * expects, so users who ran e.g. `brew install whisper-cpp` skip the CMake + * source build entirely. Exported for tests. + */ +export function adoptSystemWhisperCli( + whisperCppPath: string, + findBinary: () => string | null = findSystemWhisperCli +): boolean { + const source = findBinary(); + if (!source) return false; + + const exeName = process.platform === "win32" ? "whisper-cli.exe" : "whisper-cli"; + const targetDir = join(whisperCppPath, "build", "bin"); + try { + mkdirSync(targetDir, { recursive: true }); + copyFileSync(source, join(targetDir, exeName)); + console.log(`[devglide-voice] Adopted system whisper-cli from ${source}`); + return true; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + console.warn(`[devglide-voice] Found system whisper-cli at ${source} but failed to adopt it: ${msg}`); + return false; + } +} + /** Download a file from `url` to `dest`. Uses `fetch` (Node 18+). */ async function downloadFile(url: string, dest: string): Promise { const res = await fetch(url, { redirect: "follow" }); @@ -224,16 +286,24 @@ function cmakeAvailable(): boolean { } } +/** Outcome of an attempt to provision the whisper-cli binary. */ +type WhisperBinaryStatus = { ok: true } | { ok: false; reason: string }; + +/** Keep only the tail of long build output — compiler errors are at the end. */ +function truncateBuildOutput(text: string, maxChars = 1500): string { + return text.length <= maxChars ? text : `…${text.slice(-maxChars)}`; +} + /** Attempt to build whisper.cpp from source using CMake. */ -function buildFromSource(whisperCppPath: string): boolean { +function buildFromSource(whisperCppPath: string): WhisperBinaryStatus { if (!cmakeAvailable()) { - console.warn( - "[devglide-voice] CMake not found on PATH — cannot build whisper.cpp.\n" + + const reason = + "CMake not found on PATH — cannot build whisper.cpp.\n" + " macOS: brew install cmake (xcode-select --install alone is not enough)\n" + " Windows: winget install Kitware.CMake\n" + - " Linux: sudo apt install cmake" - ); - return false; + " Linux: sudo apt install cmake"; + console.warn(`[devglide-voice] ${reason}`); + return { ok: false, reason }; } try { @@ -256,15 +326,18 @@ function buildFromSource(whisperCppPath: string): boolean { if (whisperCliExists(whisperCppPath)) { console.log("[devglide-voice] whisper.cpp built successfully."); - return true; + return { ok: true }; } - console.warn("[devglide-voice] Build completed but whisper-cli not found."); - return false; + const reason = "CMake build completed but whisper-cli was not found in the build output."; + console.warn(`[devglide-voice] ${reason}`); + return { ok: false, reason }; } catch (err: unknown) { + // execSync errors already carry the command's stderr in their message const msg = err instanceof Error ? err.message : String(err); - console.warn(`[devglide-voice] CMake build failed: ${msg}`); - return false; + const reason = `CMake build failed: ${truncateBuildOutput(msg)}`; + console.warn(`[devglide-voice] ${reason}`); + return { ok: false, reason }; } } @@ -275,13 +348,19 @@ function buildFromSource(whisperCppPath: string): boolean { * 3. Fall back to building from source via CMake (macOS/Linux have compilers * readily available; Windows will reach this if the prebuilt download fails). */ -async function ensureWhisperBinary(): Promise { +async function ensureWhisperBinary(): Promise { const whisperCppPath = getWhisperCppPath(); // Already available — nothing to do - if (whisperCliExists(whisperCppPath)) return true; + if (whisperCliExists(whisperCppPath)) return { ok: true }; + + // Step 0: Adopt a system-installed whisper-cli (e.g. `brew install whisper-cpp`) + if (adoptSystemWhisperCli(whisperCppPath) && whisperCliExists(whisperCppPath)) { + return { ok: true }; + } // Step 1: Try prebuilt binary (Windows only — no official macOS/Linux binaries) + let downloadFailure: string | undefined; const asset = getPrebuiltAsset(); if (asset) { console.log(`[devglide-voice] whisper-cli not found — downloading prebuilt binary (${WHISPER_CPP_RELEASE_VERSION})…`); @@ -305,20 +384,21 @@ async function ensureWhisperBinary(): Promise { const src = join(extractDir, file); const dest = join(targetDir, file.split("/").pop()!); if (existsSync(src)) { - const { copyFileSync } = await import("fs"); copyFileSync(src, dest); } } if (whisperCliExists(whisperCppPath)) { console.log("[devglide-voice] Prebuilt whisper-cli installed successfully."); - return true; + return { ok: true }; } - console.warn("[devglide-voice] Prebuilt binary extracted but not found — falling back to CMake build."); + downloadFailure = "Prebuilt binary extracted but whisper-cli was not found."; + console.warn(`[devglide-voice] ${downloadFailure} Falling back to CMake build…`); } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); - console.warn(`[devglide-voice] Failed to download prebuilt binary: ${msg}`); + downloadFailure = `Failed to download prebuilt binary: ${msg}`; + console.warn(`[devglide-voice] ${downloadFailure}`); console.warn("[devglide-voice] Falling back to CMake build…"); } finally { try { unlinkSync(tmpZip); } catch { /* ignore */ } @@ -326,7 +406,12 @@ async function ensureWhisperBinary(): Promise { } // Step 2: Build from source via CMake - return buildFromSource(whisperCppPath); + const built = buildFromSource(whisperCppPath); + if (built.ok) return built; + return { + ok: false, + reason: [downloadFailure, built.reason].filter(Boolean).join("\n"), + }; } /** Check whether ffmpeg is available on PATH. */ @@ -382,8 +467,17 @@ export class LocalWhisperProvider implements TranscriptionProvider { throw new Error(ffmpeg.error!); } - // Ensure whisper-cli binary is available (download prebuilt if possible) - await ensureWhisperBinary(); + // Ensure whisper-cli binary is available (download prebuilt if possible). + // Fail fast with the real provisioning error — nodejs-whisper checks the + // same executable locations and would otherwise fail with a generic + // "whisper-cli executable not found" that hides the actual cause. + const binary = await ensureWhisperBinary(); + if (!binary.ok) { + throw new Error( + `Local whisper transcription failed — whisper-cli is not available.\n` + + `${binary.reason}\n\n${buildToolsHint()}` + ); + } // Write audio buffer to a temp file (nodejs-whisper needs a file path). // Sanitize the caller-supplied filename (strip path components, null bytes, @@ -426,7 +520,7 @@ export class LocalWhisperProvider implements TranscriptionProvider { // Detect whisper binary / build tool issues and provide actionable guidance if (/whisper.*not found|executable not found|ENOENT|cmake|build|compile/i.test(msg)) { throw new Error( - `Local whisper transcription failed: ${msg}\n\n${BUILD_TOOLS_HINT}` + `Local whisper transcription failed: ${msg}\n\n${buildToolsHint()}` ); } throw err;