Fixing local transcription flow #5

Merged
Shifty merged 3 commits from fix/local-transcription-flow into v0.7.0-update 2026-02-16 16:56:56 +00:00
3 changed files with 1027 additions and 570 deletions
Showing only changes of commit 684b61755c - Show all commits

View File

@ -1,299 +1,336 @@
if (require('electron-squirrel-startup')) { if (require("electron-squirrel-startup")) {
process.exit(0); process.exit(0);
} }
const { app, BrowserWindow, shell, ipcMain } = require('electron'); // ── Global crash handlers to prevent silent process termination ──
const { createWindow, updateGlobalShortcuts } = require('./utils/window'); process.on("uncaughtException", (error) => {
const { setupGeminiIpcHandlers, stopMacOSAudioCapture, sendToRenderer } = require('./utils/gemini'); console.error("[FATAL] Uncaught exception:", error);
const storage = require('./storage'); try {
const { sendToRenderer } = require("./utils/gemini");
sendToRenderer(
"update-status",
"Fatal error: " + (error?.message || "unknown"),
);
} catch (_) {
// sendToRenderer may not be available yet
}
});
process.on("unhandledRejection", (reason) => {
console.error("[FATAL] Unhandled promise rejection:", reason);
try {
const { sendToRenderer } = require("./utils/gemini");
sendToRenderer(
"update-status",
"Unhandled error: " +
(reason instanceof Error ? reason.message : String(reason)),
);
} catch (_) {
// sendToRenderer may not be available yet
}
});
const { app, BrowserWindow, shell, ipcMain } = require("electron");
const { createWindow, updateGlobalShortcuts } = require("./utils/window");
const {
setupGeminiIpcHandlers,
stopMacOSAudioCapture,
sendToRenderer,
} = require("./utils/gemini");
const storage = require("./storage");
const geminiSessionRef = { current: null }; const geminiSessionRef = { current: null };
let mainWindow = null; let mainWindow = null;
function createMainWindow() { function createMainWindow() {
mainWindow = createWindow(sendToRenderer, geminiSessionRef); mainWindow = createWindow(sendToRenderer, geminiSessionRef);
return mainWindow; return mainWindow;
} }
app.whenReady().then(async () => { app.whenReady().then(async () => {
// Initialize storage (checks version, resets if needed) // Initialize storage (checks version, resets if needed)
storage.initializeStorage(); storage.initializeStorage();
// Trigger screen recording permission prompt on macOS if not already granted // Trigger screen recording permission prompt on macOS if not already granted
if (process.platform === 'darwin') { if (process.platform === "darwin") {
const { desktopCapturer } = require('electron'); const { desktopCapturer } = require("electron");
desktopCapturer.getSources({ types: ['screen'] }).catch(() => {}); desktopCapturer.getSources({ types: ["screen"] }).catch(() => {});
} }
createMainWindow();
setupGeminiIpcHandlers(geminiSessionRef);
setupStorageIpcHandlers();
setupGeneralIpcHandlers();
});
app.on("window-all-closed", () => {
stopMacOSAudioCapture();
if (process.platform !== "darwin") {
app.quit();
}
});
app.on("before-quit", () => {
stopMacOSAudioCapture();
});
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
createMainWindow(); createMainWindow();
setupGeminiIpcHandlers(geminiSessionRef); }
setupStorageIpcHandlers();
setupGeneralIpcHandlers();
});
app.on('window-all-closed', () => {
stopMacOSAudioCapture();
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('before-quit', () => {
stopMacOSAudioCapture();
});
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createMainWindow();
}
}); });
function setupStorageIpcHandlers() { function setupStorageIpcHandlers() {
// ============ CONFIG ============ // ============ CONFIG ============
ipcMain.handle('storage:get-config', async () => { ipcMain.handle("storage:get-config", async () => {
try { try {
return { success: true, data: storage.getConfig() }; return { success: true, data: storage.getConfig() };
} catch (error) { } catch (error) {
console.error('Error getting config:', error); console.error("Error getting config:", error);
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
}); });
ipcMain.handle('storage:set-config', async (event, config) => { ipcMain.handle("storage:set-config", async (event, config) => {
try { try {
storage.setConfig(config); storage.setConfig(config);
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error('Error setting config:', error); console.error("Error setting config:", error);
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
}); });
ipcMain.handle('storage:update-config', async (event, key, value) => { ipcMain.handle("storage:update-config", async (event, key, value) => {
try { try {
storage.updateConfig(key, value); storage.updateConfig(key, value);
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error('Error updating config:', error); console.error("Error updating config:", error);
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
}); });
// ============ CREDENTIALS ============ // ============ CREDENTIALS ============
ipcMain.handle('storage:get-credentials', async () => { ipcMain.handle("storage:get-credentials", async () => {
try { try {
return { success: true, data: storage.getCredentials() }; return { success: true, data: storage.getCredentials() };
} catch (error) { } catch (error) {
console.error('Error getting credentials:', error); console.error("Error getting credentials:", error);
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
}); });
ipcMain.handle('storage:set-credentials', async (event, credentials) => { ipcMain.handle("storage:set-credentials", async (event, credentials) => {
try { try {
storage.setCredentials(credentials); storage.setCredentials(credentials);
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error('Error setting credentials:', error); console.error("Error setting credentials:", error);
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
}); });
ipcMain.handle('storage:get-api-key', async () => { ipcMain.handle("storage:get-api-key", async () => {
try { try {
return { success: true, data: storage.getApiKey() }; return { success: true, data: storage.getApiKey() };
} catch (error) { } catch (error) {
console.error('Error getting API key:', error); console.error("Error getting API key:", error);
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
}); });
ipcMain.handle('storage:set-api-key', async (event, apiKey) => { ipcMain.handle("storage:set-api-key", async (event, apiKey) => {
try { try {
storage.setApiKey(apiKey); storage.setApiKey(apiKey);
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error('Error setting API key:', error); console.error("Error setting API key:", error);
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
}); });
ipcMain.handle('storage:get-groq-api-key', async () => { ipcMain.handle("storage:get-groq-api-key", async () => {
try { try {
return { success: true, data: storage.getGroqApiKey() }; return { success: true, data: storage.getGroqApiKey() };
} catch (error) { } catch (error) {
console.error('Error getting Groq API key:', error); console.error("Error getting Groq API key:", error);
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
}); });
ipcMain.handle('storage:set-groq-api-key', async (event, groqApiKey) => { ipcMain.handle("storage:set-groq-api-key", async (event, groqApiKey) => {
try { try {
storage.setGroqApiKey(groqApiKey); storage.setGroqApiKey(groqApiKey);
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error('Error setting Groq API key:', error); console.error("Error setting Groq API key:", error);
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
}); });
// ============ PREFERENCES ============ // ============ PREFERENCES ============
ipcMain.handle('storage:get-preferences', async () => { ipcMain.handle("storage:get-preferences", async () => {
try { try {
return { success: true, data: storage.getPreferences() }; return { success: true, data: storage.getPreferences() };
} catch (error) { } catch (error) {
console.error('Error getting preferences:', error); console.error("Error getting preferences:", error);
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
}); });
ipcMain.handle('storage:set-preferences', async (event, preferences) => { ipcMain.handle("storage:set-preferences", async (event, preferences) => {
try { try {
storage.setPreferences(preferences); storage.setPreferences(preferences);
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error('Error setting preferences:', error); console.error("Error setting preferences:", error);
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
}); });
ipcMain.handle('storage:update-preference', async (event, key, value) => { ipcMain.handle("storage:update-preference", async (event, key, value) => {
try { try {
storage.updatePreference(key, value); storage.updatePreference(key, value);
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error('Error updating preference:', error); console.error("Error updating preference:", error);
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
}); });
// ============ KEYBINDS ============ // ============ KEYBINDS ============
ipcMain.handle('storage:get-keybinds', async () => { ipcMain.handle("storage:get-keybinds", async () => {
try { try {
return { success: true, data: storage.getKeybinds() }; return { success: true, data: storage.getKeybinds() };
} catch (error) { } catch (error) {
console.error('Error getting keybinds:', error); console.error("Error getting keybinds:", error);
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
}); });
ipcMain.handle('storage:set-keybinds', async (event, keybinds) => { ipcMain.handle("storage:set-keybinds", async (event, keybinds) => {
try { try {
storage.setKeybinds(keybinds); storage.setKeybinds(keybinds);
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error('Error setting keybinds:', error); console.error("Error setting keybinds:", error);
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
}); });
// ============ HISTORY ============ // ============ HISTORY ============
ipcMain.handle('storage:get-all-sessions', async () => { ipcMain.handle("storage:get-all-sessions", async () => {
try { try {
return { success: true, data: storage.getAllSessions() }; return { success: true, data: storage.getAllSessions() };
} catch (error) { } catch (error) {
console.error('Error getting sessions:', error); console.error("Error getting sessions:", error);
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
}); });
ipcMain.handle('storage:get-session', async (event, sessionId) => { ipcMain.handle("storage:get-session", async (event, sessionId) => {
try { try {
return { success: true, data: storage.getSession(sessionId) }; return { success: true, data: storage.getSession(sessionId) };
} catch (error) { } catch (error) {
console.error('Error getting session:', error); console.error("Error getting session:", error);
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
}); });
ipcMain.handle('storage:save-session', async (event, sessionId, data) => { ipcMain.handle("storage:save-session", async (event, sessionId, data) => {
try { try {
storage.saveSession(sessionId, data); storage.saveSession(sessionId, data);
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error('Error saving session:', error); console.error("Error saving session:", error);
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
}); });
ipcMain.handle('storage:delete-session', async (event, sessionId) => { ipcMain.handle("storage:delete-session", async (event, sessionId) => {
try { try {
storage.deleteSession(sessionId); storage.deleteSession(sessionId);
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error('Error deleting session:', error); console.error("Error deleting session:", error);
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
}); });
ipcMain.handle('storage:delete-all-sessions', async () => { ipcMain.handle("storage:delete-all-sessions", async () => {
try { try {
storage.deleteAllSessions(); storage.deleteAllSessions();
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error('Error deleting all sessions:', error); console.error("Error deleting all sessions:", error);
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
}); });
// ============ LIMITS ============ // ============ LIMITS ============
ipcMain.handle('storage:get-today-limits', async () => { ipcMain.handle("storage:get-today-limits", async () => {
try { try {
return { success: true, data: storage.getTodayLimits() }; return { success: true, data: storage.getTodayLimits() };
} catch (error) { } catch (error) {
console.error('Error getting today limits:', error); console.error("Error getting today limits:", error);
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
}); });
// ============ CLEAR ALL ============ // ============ CLEAR ALL ============
ipcMain.handle('storage:clear-all', async () => { ipcMain.handle("storage:clear-all", async () => {
try { try {
storage.clearAllData(); storage.clearAllData();
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error('Error clearing all data:', error); console.error("Error clearing all data:", error);
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
}); });
} }
function setupGeneralIpcHandlers() { function setupGeneralIpcHandlers() {
ipcMain.handle('get-app-version', async () => { ipcMain.handle("get-app-version", async () => {
return app.getVersion(); return app.getVersion();
}); });
ipcMain.handle('quit-application', async event => { ipcMain.handle("quit-application", async (event) => {
try { try {
stopMacOSAudioCapture(); stopMacOSAudioCapture();
app.quit(); app.quit();
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error('Error quitting application:', error); console.error("Error quitting application:", error);
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
}); });
ipcMain.handle('open-external', async (event, url) => { ipcMain.handle("open-external", async (event, url) => {
try { try {
await shell.openExternal(url); await shell.openExternal(url);
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error('Error opening external URL:', error); console.error("Error opening external URL:", error);
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
}); });
ipcMain.on('update-keybinds', (event, newKeybinds) => { ipcMain.on("update-keybinds", (event, newKeybinds) => {
if (mainWindow) { if (mainWindow) {
// Also save to storage // Also save to storage
storage.setKeybinds(newKeybinds); storage.setKeybinds(newKeybinds);
updateGlobalShortcuts(newKeybinds, mainWindow, sendToRenderer, geminiSessionRef); updateGlobalShortcuts(
} newKeybinds,
}); mainWindow,
sendToRenderer,
geminiSessionRef,
);
}
});
// Debug logging from renderer // Debug logging from renderer
ipcMain.on('log-message', (event, msg) => { ipcMain.on("log-message", (event, msg) => {
console.log(msg); console.log(msg);
}); });
} }

File diff suppressed because it is too large Load Diff

150
src/utils/whisperWorker.js Normal file
View File

@ -0,0 +1,150 @@
/**
* Whisper Worker runs ONNX Runtime in an isolated child process.
*
* The main Electron process forks this file and communicates via IPC messages.
* If ONNX Runtime crashes (SIGSEGV/SIGABRT inside the native Metal or CPU
* execution provider), only this worker dies the main process survives and
* can respawn the worker automatically.
*
* Protocol (parent worker):
* parent worker:
* { type: 'load', modelName, cacheDir }
* { type: 'transcribe', audioBase64 } // PCM 16-bit 16kHz as base64
* { type: 'shutdown' }
*
* worker parent:
* { type: 'load-result', success, error? }
* { type: 'transcribe-result', success, text?, error? }
* { type: 'status', message }
* { type: 'ready' }
*/
let whisperPipeline = null;
function pcm16ToFloat32(pcm16Buffer) {
if (!pcm16Buffer || pcm16Buffer.length === 0) {
return new Float32Array(0);
}
const alignedLength =
pcm16Buffer.length % 2 === 0 ? pcm16Buffer.length : pcm16Buffer.length - 1;
const samples = alignedLength / 2;
const float32 = new Float32Array(samples);
for (let i = 0; i < samples; i++) {
float32[i] = pcm16Buffer.readInt16LE(i * 2) / 32768;
}
return float32;
}
async function loadModel(modelName, cacheDir) {
if (whisperPipeline) {
send({ type: "load-result", success: true });
return;
}
try {
send({
type: "status",
message: "Loading Whisper model (first time may take a while)...",
});
const { pipeline, env } = await import("@huggingface/transformers");
env.cacheDir = cacheDir;
whisperPipeline = await pipeline(
"automatic-speech-recognition",
modelName,
{
dtype: "q8",
device: "cpu",
},
);
send({ type: "load-result", success: true });
} catch (error) {
send({ type: "load-result", success: false, error: error.message });
}
}
async function transcribe(audioBase64) {
if (!whisperPipeline) {
send({
type: "transcribe-result",
success: false,
error: "Whisper pipeline not loaded",
});
return;
}
try {
const pcm16Buffer = Buffer.from(audioBase64, "base64");
if (pcm16Buffer.length < 2) {
send({
type: "transcribe-result",
success: false,
error: "Audio buffer too small",
});
return;
}
// Cap at ~30 seconds (16kHz, 16-bit mono)
const maxBytes = 16000 * 2 * 30;
const audioData =
pcm16Buffer.length > maxBytes
? pcm16Buffer.slice(0, maxBytes)
: pcm16Buffer;
const float32Audio = pcm16ToFloat32(audioData);
if (float32Audio.length === 0) {
send({
type: "transcribe-result",
success: false,
error: "Empty audio after conversion",
});
return;
}
const result = await whisperPipeline(float32Audio, {
sampling_rate: 16000,
language: "en",
task: "transcribe",
});
const text = result.text?.trim() || "";
send({ type: "transcribe-result", success: true, text });
} catch (error) {
send({
type: "transcribe-result",
success: false,
error: error.message || String(error),
});
}
}
function send(msg) {
try {
if (process.send) {
process.send(msg);
}
} catch (_) {
// Parent may have disconnected
}
}
process.on("message", (msg) => {
switch (msg.type) {
case "load":
loadModel(msg.modelName, msg.cacheDir).catch((err) => {
send({ type: "load-result", success: false, error: err.message });
});
break;
case "transcribe":
transcribe(msg.audioBase64).catch((err) => {
send({ type: "transcribe-result", success: false, error: err.message });
});
break;
case "shutdown":
process.exit(0);
break;
}
});
// Signal readiness to parent
send({ type: "ready" });