Skip to content

Commit fe7712c

Browse files
tclaude
andcommitted
feat(voice): /voice setup check + whisper.cpp detection (slice 1)
Surface the existing core whisper.cpp engine via a `/voice` slash command and add the settings schema for it. No mic capture yet — this is the safe, self-contained foundation per docs/VOICE_INPUT.md. Core: - Add VoiceConfig (provider | binPath | modelPath) to settings types, re-exported from @deepcode/core (the JSON schema already had the block). - New detectVoice() (voice/detect.ts): resolves the whisper binary (settings.binPath, else whisper-cli/whisper on PATH) and the model (settings.modelPath, else ~/.deepcode/models/whisper-base.en.bin), never throws — missing pieces become `problems`. Injectable probes for deterministic tests. - validateSettingsShallow now flags an unknown voice.provider. CLI: - /voice reports readiness or prints actionable setup steps (+ per-issue detail); `/voice setup` always shows install instructions. - SessionContext gains an optional `home` (honors --home) for the default model-path probe; wired in the REPL. Tests: 9 core detection cases, 1 schema case, 3 CLI messaging cases. Updates the /voice BEHAVIOR_PARITY row (✗ → ✓, 🔄 → 🟡). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 96b4f8a commit fe7712c

12 files changed

Lines changed: 478 additions & 50 deletions

File tree

apps/cli/src/commands.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,9 @@ export interface SessionContext {
127127
credsStore?: CredentialsStore;
128128
/** User settings.json path (REPL-injected, honors --home) — backs /config set. */
129129
userSettingsPath?: string;
130+
/** Home dir override (REPL-injected from --home) — backs default-path lookups
131+
* like /voice's `~/.deepcode/models/...` model probe. Defaults to os.homedir(). */
132+
home?: string;
130133
sessionId: string;
131134
sessions: SessionManager;
132135
usage: {
@@ -1134,6 +1137,57 @@ export const BtwCommand: SlashCommand = {
11341137
},
11351138
};
11361139

1140+
export const VoiceCommand: SlashCommand = {
1141+
name: '/voice',
1142+
description: 'Check local voice-input (whisper.cpp) setup; `/voice setup` shows install steps.',
1143+
async run(args, ctx) {
1144+
const { detectVoice } = await import('@deepcode/core');
1145+
const status = await detectVoice(ctx.settings.voice, { home: ctx.home });
1146+
const forceSetup = (args[0] ?? '').toLowerCase() === 'setup';
1147+
1148+
if (status.ready && !forceSetup) {
1149+
return [
1150+
'🎙 Voice input is ready — whisper.cpp, fully local (no audio leaves your machine).',
1151+
` binary: ${status.binPath}`,
1152+
` model: ${status.modelPath}`,
1153+
'',
1154+
'Dictate from the REPL with the voice key (default Ctrl+V; remap in keybindings.json).',
1155+
'Note: live mic capture lands in a follow-up — this step ships setup + detection.',
1156+
];
1157+
}
1158+
1159+
const lines: string[] = [
1160+
status.ready
1161+
? '🎙 Voice input is ready. Setup reference below.'
1162+
: '🎙 Voice input is not set up yet. Enable local dictation (whisper.cpp — no cloud):',
1163+
'',
1164+
'Detected:',
1165+
` ${status.binPath ? '✓' : '✗'} whisper binary ${status.binPath ?? '(not found)'}`,
1166+
` ${status.modelPath ? '✓' : '✗'} model ${status.modelPath ?? '(not found)'}`,
1167+
];
1168+
if (status.problems.length) {
1169+
lines.push('', 'Issues:');
1170+
for (const p of status.problems) lines.push(` • ${p}`);
1171+
}
1172+
lines.push(
1173+
'',
1174+
'Setup:',
1175+
' 1. Install whisper.cpp',
1176+
' macOS: brew install whisper-cpp',
1177+
' Linux: build https://github.com/ggerganov/whisper.cpp, put `whisper` on PATH',
1178+
' 2. Download a model (base.en ≈ 140 MB is a good default) and save it:',
1179+
' mkdir -p ~/.deepcode/models',
1180+
' cp ggml-base.en.bin ~/.deepcode/models/whisper-base.en.bin',
1181+
' 3. (optional) Point DeepCode at custom paths in ~/.deepcode/settings.json:',
1182+
' { "voice": { "binPath": "/opt/homebrew/bin/whisper-cli",',
1183+
' "modelPath": "~/.deepcode/models/whisper-base.en.bin" } }',
1184+
'',
1185+
'Full guide: docs/VOICE_INPUT.md',
1186+
);
1187+
return lines;
1188+
},
1189+
};
1190+
11371191
export const BUILTIN_COMMANDS: SlashCommand[] = [
11381192
HelpCommand,
11391193
ClearCommand,
@@ -1170,6 +1224,7 @@ export const BUILTIN_COMMANDS: SlashCommand[] = [
11701224
UpgradeCommand,
11711225
PrivacySettingsCommand,
11721226
BtwCommand,
1227+
VoiceCommand,
11731228
];
11741229

11751230
// ──────────────────────────────────────────────────────────────────────────

apps/cli/src/repl.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,7 @@ export async function startRepl(opts: ReplOpts): Promise<number> {
436436
creds,
437437
credsStore,
438438
userSettingsPath: settingsPaths({ cwd, home: opts.home }).userPath,
439+
home: opts.home,
439440
sessionId: session.id,
440441
sessions,
441442
usage: { inputTokens: 0, outputTokens: 0, reasoningTokens: 0, cacheReadTokens: 0 },

apps/cli/src/voice-cmd.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// Tests for the /voice slash command messaging. Detection logic itself is
2+
// unit-tested in core (voice/detect.test.ts); here we drive the command end to
3+
// end with real temp files so the "ready" path is deterministic, and bogus
4+
// configured paths so the "not set up" path never depends on the host's PATH.
5+
6+
import { afterEach, describe, expect, it } from 'vitest';
7+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
8+
import { tmpdir } from 'node:os';
9+
import { join } from 'node:path';
10+
import { SessionManager } from '@deepcode/core';
11+
import { CommandRegistry, type SessionContext } from './commands.js';
12+
13+
const reg = new CommandRegistry();
14+
const tmps: string[] = [];
15+
async function tmpDir(): Promise<string> {
16+
const d = await mkdtemp(join(tmpdir(), 'dc-voice-'));
17+
tmps.push(d);
18+
return d;
19+
}
20+
afterEach(async () => {
21+
await Promise.all(tmps.splice(0).map((d) => rm(d, { recursive: true, force: true })));
22+
});
23+
24+
function ctx(overrides: Partial<SessionContext> = {}): SessionContext {
25+
return {
26+
cwd: '/tmp/x',
27+
model: 'deepseek-chat',
28+
mode: 'default',
29+
effort: 'medium',
30+
settings: {},
31+
creds: { apiKey: 'sk-test' },
32+
sessionId: 's1',
33+
sessions: new SessionManager({ root: '/tmp/x' }),
34+
usage: { inputTokens: 0, outputTokens: 0, reasoningTokens: 0, cacheReadTokens: 0 },
35+
...overrides,
36+
};
37+
}
38+
39+
const run = (args: string[], c: SessionContext) => reg.match('/voice')!.cmd.run(args, c);
40+
41+
describe('/voice', () => {
42+
it('reports ready when configured binary + model both exist', async () => {
43+
const dir = await tmpDir();
44+
const binPath = join(dir, 'whisper-cli');
45+
const modelPath = join(dir, 'model.bin');
46+
await writeFile(binPath, '#!/bin/sh\n');
47+
await writeFile(modelPath, 'GGML');
48+
const out = (await run([], ctx({ settings: { voice: { binPath, modelPath } } }))).join('\n');
49+
expect(out).toMatch(/ready/i);
50+
expect(out).toContain(binPath);
51+
expect(out).toContain(modelPath);
52+
expect(out).toMatch(/Ctrl\+V/);
53+
});
54+
55+
it('prints setup steps + issues when configured paths are missing', async () => {
56+
const out = (
57+
await run(
58+
[],
59+
ctx({ settings: { voice: { binPath: '/no/such/whisper', modelPath: '/no/such/m.bin' } } }),
60+
)
61+
).join('\n');
62+
expect(out).toMatch(/not set up yet/i);
63+
expect(out).toMatch(/brew install whisper-cpp/);
64+
expect(out).toMatch(/docs\/VOICE_INPUT\.md/);
65+
// The specific configured-but-missing problems surface under "Issues:".
66+
expect(out).toMatch(/Issues:/);
67+
expect(out).toContain('Configured voice.binPath not found: /no/such/whisper');
68+
expect(out).toContain('Configured voice.modelPath not found: /no/such/m.bin');
69+
});
70+
71+
it('`/voice setup` always shows install steps, even when ready', async () => {
72+
const dir = await tmpDir();
73+
const binPath = join(dir, 'whisper-cli');
74+
const modelPath = join(dir, 'model.bin');
75+
await writeFile(binPath, '');
76+
await writeFile(modelPath, '');
77+
const out = (await run(['setup'], ctx({ settings: { voice: { binPath, modelPath } } }))).join(
78+
'\n',
79+
);
80+
expect(out).toMatch(/Setup:/);
81+
expect(out).toMatch(/brew install whisper-cpp/);
82+
// Still acknowledges it's already ready.
83+
expect(out).toMatch(/ready/i);
84+
});
85+
});

0 commit comments

Comments
 (0)