Skip to content

Commit 258fd64

Browse files
feat(speech): add configurable persistent local Whisper (#29)
1 parent bc6dae5 commit 258fd64

15 files changed

Lines changed: 825 additions & 100 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,6 @@ node_modules/
44
.whisper-models/
55
eng.traineddata
66
dist/
7+
bin/
78
.DS_Store
89
*.log

README.md

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@ It is free and open source. Processing stays on your machine, and the only thing
3434

3535
- **Invisible overlay.** Windows stay out of Zoom, Google Meet, Microsoft Teams, Discord, and OBS captures. You see the answer, the call does not.
3636
- **Hidden during screen share.** When a share starts, the app can hide every window on its own.
37-
- **Real-time voice.** Speech is split on natural pauses instead of a fixed timer, so one spoken question stays one question. Filler phrases that Whisper invents on silence are dropped before they reach the model.
38-
- **Streamed answers.** Replies appear word by word as the model generates them, in both the chat and the floating window.
37+
- **Flexible local voice.** Choose manual start/stop capture or automatic voice-activity detection without fixed-timer sentence cuts.
38+
- **Configurable streamed answers.** Route voice replies to chat, the floating overlay, or both.
3939
- **Direct image analysis.** Screenshots go straight to Gemini for visual reasoning, with no slow OCR step in between.
4040
- **Session memory.** The whole conversation is remembered, so follow-ups, edge cases, and optimizations keep their context.
4141
- **Language aware.** Tailored answers for C++, C, Python, Java, and JavaScript.
@@ -116,8 +116,14 @@ AZURE_SPEECH_REGION=your_region
116116
# Local Whisper option
117117
WHISPER_COMMAND=whisper
118118
WHISPER_MODEL_DIR=.whisper-models
119-
WHISPER_MODEL=turbo
120-
WHISPER_LANGUAGE=en
119+
WHISPER_MODEL=small
120+
WHISPER_LANGUAGE=auto
121+
WHISPER_DEVICE=auto
122+
WHISPER_PYTHON=
123+
WHISPER_CAPTURE_MODE=vad
124+
WHISPER_RESPONSE_TARGET=both
125+
WHISPER_MANUAL_MAX_MS=90000
126+
WHISPER_GPU_IDLE_MS=60000
121127
```
122128

123129
Speech is optional. If no provider is configured, the microphone button hides itself across the app.
@@ -126,15 +132,15 @@ Speech is optional. If no provider is configured, the microphone button hides it
126132

127133
You can use local Whisper for offline transcription or Azure Speech for a cloud option.
128134

129-
For local Whisper, `./setup.sh` handles the full setup. It creates `.venv-whisper`, installs `openai-whisper`, points `.env` at the virtual environment, creates `.whisper-models`, and runs a quick speech test. You only need Python 3.10 or newer and ffmpeg on your system. Install those with `./setup.sh --install-system-deps`, or add `ffmpeg` and `sox` yourself.
135+
For local Whisper, `./setup.sh` handles the full setup. It creates `.venv-whisper`, installs `openai-whisper`, points `.env` at the virtual environment, creates `.whisper-models`, and runs a quick speech test. The app reads its own PCM WAV recordings directly; ffmpeg is only needed when transcribing other audio formats through the CLI fallback.
130136

131137
For Azure Speech, create a Speech resource in the [Azure Portal](https://portal.azure.com/), then add the key and region to `.env` with `SPEECH_PROVIDER=azure`.
132138

133139
## How it works
134140

135-
1. **Ask.** Speak the question or press the screenshot shortcut. The microphone listens for natural pauses on its own and does not cut you off mid sentence.
141+
1. **Ask.** Use automatic pause detection, choose manual start/stop capture in Settings, or use the screenshot shortcut.
136142
2. **Reason.** Gemini reads the audio or image with full conversation context and works toward a precise answer.
137-
3. **Answer.** The response streams into the overlay in real time, with formatted text and highlighted code.
143+
3. **Answer.** Voice responses stream to chat, the overlay, or both, according to Settings.
138144

139145
## Keyboard shortcuts
140146

@@ -156,9 +162,9 @@ OpenCluely is under active development. The core is stable and improvements ship
156162
- Stealth overlay with a draggable command bar and a click through toggle
157163
- Hidden during screen share, with automatic hiding when a share begins
158164
- Screenshot capture with direct Gemini analysis, no OCR step
159-
- Real-time voice input that segments on natural pauses, not a blind timer
160-
- Utterance coalescing so one spoken question becomes one answer
161-
- Streamed answers that render word by word in the chat and the overlay
165+
- Configurable manual or VAD-driven voice capture
166+
- Persistent local Whisper worker with optional CUDA acceleration and idle GPU release
167+
- Configurable chat/overlay routing for streamed voice answers
162168
- Whisper hallucination filter that drops phantom phrases on silence
163169
- AI response window with markdown and syntax highlighting
164170
- Global shortcuts for capture, visibility, interaction, chat, and settings

env.example

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ WHISPER_COMMAND=whisper
2020
# app-data folder (recommended). Set an ABSOLUTE path to override; a relative
2121
# path is ignored because it cannot be resolved reliably in packaged builds.
2222
# WHISPER_MODEL_DIR=
23-
WHISPER_MODEL=turbo
24-
WHISPER_LANGUAGE=en
23+
WHISPER_MODEL=small
24+
WHISPER_LANGUAGE=auto
2525
WHISPER_SEGMENT_MS=4000
26+
WHISPER_DEVICE=auto
27+
WHISPER_PYTHON=
28+
WHISPER_CAPTURE_MODE=vad
29+
WHISPER_RESPONSE_TARGET=both
30+
WHISPER_MANUAL_MAX_MS=90000
31+
WHISPER_GPU_IDLE_MS=60000

main.js

Lines changed: 147 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
const path = require("path");
22
const fs = require("fs");
3+
const { fileURLToPath } = require("url");
34
const { app, BrowserWindow, globalShortcut, session, ipcMain } = require("electron");
45

56
// ── Resolve a stable .env location ──
@@ -332,12 +333,56 @@ class ApplicationController {
332333
}
333334

334335
setupPermissions() {
335-
session.defaultSession.setPermissionRequestHandler(
336-
(webContents, permission, callback) => {
337-
const allowedPermissions = ["microphone", "camera", "display-capture"];
338-
const granted = allowedPermissions.includes(permission);
336+
const appSession = session.defaultSession;
337+
const isTrustedAppContents = (webContents) => {
338+
if (!webContents || webContents.isDestroyed()) {
339+
return false;
340+
}
341+
try {
342+
const pagePath = path.resolve(fileURLToPath(webContents.getURL()));
343+
const appRoot = path.resolve(__dirname);
344+
const normalizeForComparison = (value) => process.platform === "win32"
345+
? value.toLowerCase()
346+
: value;
347+
const page = normalizeForComparison(pagePath);
348+
const root = normalizeForComparison(appRoot + path.sep);
349+
return page.startsWith(root);
350+
} catch (_) {
351+
return false;
352+
}
353+
};
339354

340-
logger.debug("Permission request", { permission, granted });
355+
// Electron exposes camera/microphone access as the single `media`
356+
// permission. The requested device type is provided separately in details.
357+
appSession.setPermissionCheckHandler(
358+
(webContents, permission, _requestingOrigin, details = {}) => {
359+
if (!isTrustedAppContents(webContents)) {
360+
return false;
361+
}
362+
if (permission === "media") {
363+
return !details.mediaType || details.mediaType === "audio";
364+
}
365+
return permission === "display-capture";
366+
}
367+
);
368+
369+
appSession.setPermissionRequestHandler(
370+
(webContents, permission, callback, details = {}) => {
371+
let granted = false;
372+
if (isTrustedAppContents(webContents)) {
373+
if (permission === "media") {
374+
const mediaTypes = Array.isArray(details.mediaTypes) ? details.mediaTypes : [];
375+
granted = mediaTypes.length === 0 || mediaTypes.includes("audio");
376+
} else {
377+
granted = permission === "display-capture";
378+
}
379+
}
380+
381+
logger.debug("Permission request", {
382+
permission,
383+
mediaTypes: details.mediaTypes || [],
384+
granted
385+
});
341386
callback(granted);
342387
}
343388
);
@@ -373,15 +418,11 @@ class ApplicationController {
373418

374419
setupServiceEventHandlers() {
375420
speechService.on("recording-started", () => {
376-
BrowserWindow.getAllWindows().forEach((window) => {
377-
window.webContents.send("recording-started");
378-
});
421+
windowManager.handleRecordingStarted();
379422
});
380423

381424
speechService.on("recording-stopped", () => {
382-
BrowserWindow.getAllWindows().forEach((window) => {
383-
window.webContents.send("recording-stopped");
384-
});
425+
windowManager.handleRecordingStopped();
385426
});
386427

387428
speechService.on("transcription", (text) => {
@@ -787,7 +828,7 @@ class ApplicationController {
787828
try {
788829
const installer = this.getWhisperInstaller();
789830
const sender = event.sender;
790-
const result = await installer.downloadModel(modelName || 'turbo', {
831+
const result = await installer.downloadModel(modelName || 'small', {
791832
onProgress: (line) => {
792833
try { sender.send("install-progress", line); } catch (_) { /* ignore */ }
793834
},
@@ -917,7 +958,6 @@ class ApplicationController {
917958
if (currentStatus.isRecording) {
918959
try {
919960
speechService.stopRecording();
920-
windowManager.hideChatWindow();
921961
logger.info("Speech recognition stopped via global shortcut");
922962
} catch (error) {
923963
logger.error("Error stopping speech recognition:", error);
@@ -1193,19 +1233,26 @@ class ApplicationController {
11931233
return;
11941234
}
11951235

1196-
// Show the live transcript right away in all windows.
1236+
// Route speech UI events according to the user's response-target setting.
11971237
sessionManager.addUserInput(fragment, 'speech');
1198-
BrowserWindow.getAllWindows().forEach((window) => {
1199-
window.webContents.send("transcription-received", { text: fragment });
1200-
});
1238+
this.sendToVoiceResponseWindows("transcription-received", { text: fragment });
12011239

12021240
this._utteranceBuffer = this._utteranceBuffer
12031241
? `${this._utteranceBuffer} ${fragment}`
12041242
: fragment;
12051243

12061244
if (this._utteranceTimer) {
12071245
clearTimeout(this._utteranceTimer);
1246+
this._utteranceTimer = null;
12081247
}
1248+
1249+
// Manual capture emits one complete transcript after the user presses stop,
1250+
// so no debounce/coalescing delay is needed.
1251+
if (speechService.isManualCaptureMode()) {
1252+
this.dispatchCoalescedUtterance();
1253+
return;
1254+
}
1255+
12091256
this._utteranceTimer = setTimeout(() => {
12101257
this._utteranceTimer = null;
12111258
this.dispatchCoalescedUtterance();
@@ -1278,26 +1325,25 @@ class ApplicationController {
12781325
const skillsRequiringProgrammingLanguage = ['dsa'];
12791326
const needsProgrammingLanguage = skillsRequiringProgrammingLanguage.includes(this.activeSkill);
12801327

1281-
// Stream the answer so it renders progressively in the chat + overlay.
1328+
// Stream the answer progressively to the configured speech target.
12821329
// A unique messageId ties the start/chunk/final events to one bubble so
12831330
// the UI never duplicates or interleaves concurrent responses.
12841331
this._responseSeq = (this._responseSeq || 0) + 1;
12851332
messageId = `tr-${Date.now()}-${this._responseSeq}`;
1286-
windowManager.broadcastToAllWindows("transcription-llm-response-start", {
1333+
this.sendToVoiceResponseWindows("transcription-llm-response-start", {
12871334
messageId,
12881335
skill: this.activeSkill
12891336
});
1290-
// Surface the overlay immediately so streamed tokens are visible there
1291-
// too, instead of the overlay only appearing once the full answer lands.
1292-
windowManager.showLLMLoading();
1293-
1337+
if (this.shouldShowVoiceOverlay()) {
1338+
windowManager.showLLMLoading();
1339+
}
12941340
const llmResult = await llmService.processTranscriptionWithIntelligentResponseStream(
12951341
cleanText,
12961342
this.activeSkill,
12971343
sessionHistory.recent,
12981344
needsProgrammingLanguage ? this.codingLanguage : null,
12991345
(delta) => {
1300-
windowManager.broadcastToAllWindows("transcription-llm-response-chunk", {
1346+
this.sendToVoiceResponseWindows("transcription-llm-response-chunk", {
13011347
messageId,
13021348
delta
13031349
});
@@ -1313,18 +1359,15 @@ class ApplicationController {
13131359
isTranscriptionResponse: true
13141360
});
13151361

1316-
// Send response to chat windows
1317-
this.broadcastTranscriptionLLMResponse(llmResult);
1318-
1319-
// Also display in the overlay (LLM response) window so the answer
1320-
// appears in both the chat panel and the floating overlay, mirroring
1321-
// the behaviour of screenshot/image responses.
1322-
windowManager.showLLMResponse(llmResult.response, {
1323-
skill: this.activeSkill,
1324-
processingTime: llmResult.metadata.processingTime,
1325-
usedFallback: llmResult.metadata.usedFallback,
1326-
isTranscriptionResponse: true
1327-
});
1362+
this.sendTranscriptionLLMResponseToVoiceTargets(llmResult);
1363+
if (this.shouldShowVoiceOverlay()) {
1364+
windowManager.showLLMResponse(llmResult.response, {
1365+
skill: this.activeSkill,
1366+
processingTime: llmResult.metadata.processingTime,
1367+
usedFallback: llmResult.metadata.usedFallback,
1368+
isTranscriptionResponse: true
1369+
});
1370+
}
13281371

13291372
logger.info("Transcription LLM response completed", {
13301373
responseLength: llmResult.response.length,
@@ -1344,7 +1387,7 @@ class ApplicationController {
13441387
// Try to provide a fallback response
13451388
try {
13461389
const fallbackResult = llmService.generateIntelligentFallbackResponse(text, this.activeSkill);
1347-
// Carry the streaming messageId so the chat/overlay replace the live
1390+
// Carry the streaming messageId so the target replaces the live
13481391
// bubble instead of leaving it stuck and appending a duplicate.
13491392
if (messageId) {
13501393
fallbackResult.metadata = { ...fallbackResult.metadata, messageId };
@@ -1358,14 +1401,15 @@ class ApplicationController {
13581401
fallbackReason: error.message
13591402
});
13601403

1361-
this.broadcastTranscriptionLLMResponse(fallbackResult);
1362-
// Mirror to overlay window for consistency
1363-
windowManager.showLLMResponse(fallbackResult.response, {
1364-
skill: this.activeSkill,
1365-
processingTime: fallbackResult.metadata.processingTime,
1366-
usedFallback: true,
1367-
isTranscriptionResponse: true
1368-
});
1404+
this.sendTranscriptionLLMResponseToVoiceTargets(fallbackResult);
1405+
if (this.shouldShowVoiceOverlay()) {
1406+
windowManager.showLLMResponse(fallbackResult.response, {
1407+
skill: this.activeSkill,
1408+
processingTime: fallbackResult.metadata.processingTime,
1409+
usedFallback: true,
1410+
isTranscriptionResponse: true
1411+
});
1412+
}
13691413
logger.info("Used fallback response for transcription", {
13701414
skill: this.activeSkill,
13711415
fallbackResponse: fallbackResult.response
@@ -1445,6 +1489,48 @@ class ApplicationController {
14451489
windowManager.broadcastToAllWindows("transcription-llm-response", broadcastData);
14461490
}
14471491

1492+
sendToChatWindow(channel, data) {
1493+
const chatWindow = windowManager.getWindow("chat");
1494+
if (!chatWindow || chatWindow.isDestroyed()) {
1495+
logger.warn("Chat window unavailable for speech event", { channel });
1496+
return;
1497+
}
1498+
chatWindow.webContents.send(channel, data);
1499+
}
1500+
1501+
getVoiceResponseTarget() {
1502+
const configured = String(process.env.WHISPER_RESPONSE_TARGET || 'both').trim().toLowerCase();
1503+
return ['chat', 'overlay', 'both'].includes(configured) ? configured : 'both';
1504+
}
1505+
1506+
shouldShowVoiceOverlay() {
1507+
return ['overlay', 'both'].includes(this.getVoiceResponseTarget());
1508+
}
1509+
1510+
sendToVoiceResponseWindows(channel, data) {
1511+
const target = this.getVoiceResponseTarget();
1512+
if (target === 'chat' || target === 'both') {
1513+
this.sendToChatWindow(channel, data);
1514+
}
1515+
if (target === 'overlay' || target === 'both') {
1516+
const responseWindow = windowManager.getWindow("llmResponse");
1517+
if (responseWindow && !responseWindow.isDestroyed()) {
1518+
responseWindow.webContents.send(channel, data);
1519+
}
1520+
}
1521+
}
1522+
1523+
sendTranscriptionLLMResponseToVoiceTargets(llmResult) {
1524+
const data = {
1525+
response: llmResult.response,
1526+
metadata: llmResult.metadata,
1527+
messageId: llmResult.metadata && llmResult.metadata.messageId,
1528+
skill: this.activeSkill,
1529+
isTranscriptionResponse: true
1530+
};
1531+
this.sendToVoiceResponseWindows("transcription-llm-response", data);
1532+
}
1533+
14481534
onWindowAllClosed() {
14491535
if (process.platform !== "darwin") {
14501536
app.quit();
@@ -1474,6 +1560,7 @@ class ApplicationController {
14741560

14751561
onWillQuit() {
14761562
globalShortcut.unregisterAll();
1563+
speechService.shutdown();
14771564
windowManager.destroyAllWindows();
14781565

14791566
const sessionStats = sessionManager.getMemoryUsage();
@@ -1512,8 +1599,12 @@ class ApplicationController {
15121599
azureKey: process.env.AZURE_SPEECH_KEY || "",
15131600
azureRegion: process.env.AZURE_SPEECH_REGION || "",
15141601
whisperCommand: process.env.WHISPER_COMMAND || "",
1515-
whisperModel: process.env.WHISPER_MODEL || "turbo",
1516-
whisperLanguage: process.env.WHISPER_LANGUAGE || "en",
1602+
whisperModel: process.env.WHISPER_MODEL || "small",
1603+
whisperLanguage: process.env.WHISPER_LANGUAGE || "auto",
1604+
whisperDevice: process.env.WHISPER_DEVICE || "auto",
1605+
whisperCaptureMode: process.env.WHISPER_CAPTURE_MODE ||
1606+
(process.env.WHISPER_MANUAL_CAPTURE === "true" ? "manual" : "vad"),
1607+
whisperResponseTarget: process.env.WHISPER_RESPONSE_TARGET || "both",
15171608
whisperSegmentMs: process.env.WHISPER_SEGMENT_MS || "4000",
15181609
geminiKey: process.env.GEMINI_API_KEY || "",
15191610

@@ -1572,6 +1663,15 @@ class ApplicationController {
15721663
if (settings.whisperLanguage !== undefined) {
15731664
envUpdates.WHISPER_LANGUAGE = settings.whisperLanguage;
15741665
}
1666+
if (["auto", "cpu", "cuda"].includes(settings.whisperDevice)) {
1667+
envUpdates.WHISPER_DEVICE = settings.whisperDevice;
1668+
}
1669+
if (["manual", "vad"].includes(settings.whisperCaptureMode)) {
1670+
envUpdates.WHISPER_CAPTURE_MODE = settings.whisperCaptureMode;
1671+
}
1672+
if (["chat", "overlay", "both"].includes(settings.whisperResponseTarget)) {
1673+
envUpdates.WHISPER_RESPONSE_TARGET = settings.whisperResponseTarget;
1674+
}
15751675
if (settings.whisperSegmentMs !== undefined) {
15761676
envUpdates.WHISPER_SEGMENT_MS = String(settings.whisperSegmentMs);
15771677
}

0 commit comments

Comments
 (0)