Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion bin/claude-md-template.js
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
4 changes: 4 additions & 0 deletions bin/devglide.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, "..");
Expand Down Expand Up @@ -582,6 +583,9 @@ function runSetup() {
}
}

// Report voice/local-whisper environment dependencies (non-fatal)
runDoctor();

console.log("\n Setup complete!\n");
}

Expand Down
122 changes: 122 additions & 0 deletions bin/doctor.js
Original file line number Diff line number Diff line change
@@ -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);
}
81 changes: 81 additions & 0 deletions bin/doctor.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/apps/voice/src/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
52 changes: 52 additions & 0 deletions src/apps/voice/src/providers/local-whisper.adopt.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
42 changes: 42 additions & 0 deletions src/apps/voice/src/providers/local-whisper.hint.test.ts
Original file line number Diff line number Diff line change
@@ -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');
}
});
});
Loading
Loading