Merge pull request 'Fixing local transcription flow' (#5) from fix/local-transcription-flow into v0.7.0-update
Reviewed-on: #5
This commit is contained in:
commit
2ebde60dcd
@ -1,14 +1,15 @@
|
||||
const { FusesPlugin } = require('@electron-forge/plugin-fuses');
|
||||
const { FuseV1Options, FuseVersion } = require('@electron/fuses');
|
||||
const { FusesPlugin } = require("@electron-forge/plugin-fuses");
|
||||
const { FuseV1Options, FuseVersion } = require("@electron/fuses");
|
||||
|
||||
module.exports = {
|
||||
packagerConfig: {
|
||||
asar: {
|
||||
unpack: '**/{onnxruntime-node,onnxruntime-common,@huggingface/transformers,sharp,@img}/**',
|
||||
unpack:
|
||||
"**/{onnxruntime-node,onnxruntime-common,@huggingface/transformers,sharp,@img}/**",
|
||||
},
|
||||
extraResource: ['./src/assets/SystemAudioDump'],
|
||||
name: 'Mastermind',
|
||||
icon: 'src/assets/logo',
|
||||
extraResource: ["./src/assets/SystemAudioDump"],
|
||||
name: "Mastermind",
|
||||
icon: "src/assets/logo",
|
||||
// use `security find-identity -v -p codesigning` to find your identity
|
||||
// for macos signing
|
||||
// also fuck apple
|
||||
@ -27,40 +28,44 @@ module.exports = {
|
||||
// teamId: 'your team id',
|
||||
// },
|
||||
},
|
||||
rebuildConfig: {},
|
||||
rebuildConfig: {
|
||||
// Ensure onnxruntime-node is rebuilt against Electron's Node.js headers
|
||||
// so the native binding matches the ABI used in packaged builds.
|
||||
onlyModules: ["onnxruntime-node", "sharp"],
|
||||
},
|
||||
makers: [
|
||||
{
|
||||
name: '@electron-forge/maker-squirrel',
|
||||
name: "@electron-forge/maker-squirrel",
|
||||
config: {
|
||||
name: 'mastermind',
|
||||
productName: 'Mastermind',
|
||||
shortcutName: 'Mastermind',
|
||||
name: "mastermind",
|
||||
productName: "Mastermind",
|
||||
shortcutName: "Mastermind",
|
||||
createDesktopShortcut: true,
|
||||
createStartMenuShortcut: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '@electron-forge/maker-dmg',
|
||||
platforms: ['darwin'],
|
||||
name: "@electron-forge/maker-dmg",
|
||||
platforms: ["darwin"],
|
||||
},
|
||||
{
|
||||
name: '@reforged/maker-appimage',
|
||||
platforms: ['linux'],
|
||||
name: "@reforged/maker-appimage",
|
||||
platforms: ["linux"],
|
||||
config: {
|
||||
options: {
|
||||
name: 'Mastermind',
|
||||
productName: 'Mastermind',
|
||||
genericName: 'AI Assistant',
|
||||
description: 'AI assistant for interviews and learning',
|
||||
categories: ['Development', 'Education'],
|
||||
icon: 'src/assets/logo.png'
|
||||
}
|
||||
name: "Mastermind",
|
||||
productName: "Mastermind",
|
||||
genericName: "AI Assistant",
|
||||
description: "AI assistant for interviews and learning",
|
||||
categories: ["Development", "Education"],
|
||||
icon: "src/assets/logo.png",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
plugins: [
|
||||
{
|
||||
name: '@electron-forge/plugin-auto-unpack-natives',
|
||||
name: "@electron-forge/plugin-auto-unpack-natives",
|
||||
config: {},
|
||||
},
|
||||
// Fuses are used to enable/disable various Electron functionality
|
||||
|
||||
@ -9,7 +9,8 @@
|
||||
"package": "electron-forge package",
|
||||
"make": "electron-forge make",
|
||||
"publish": "electron-forge publish",
|
||||
"lint": "echo \"No linting configured\""
|
||||
"lint": "echo \"No linting configured\"",
|
||||
"postinstall": "electron-rebuild -f -w onnxruntime-node"
|
||||
},
|
||||
"keywords": [
|
||||
"mastermind",
|
||||
@ -33,6 +34,7 @@
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron/rebuild": "^3.7.1",
|
||||
"@electron-forge/cli": "^7.8.1",
|
||||
"@electron-forge/maker-deb": "^7.8.1",
|
||||
"@electron-forge/maker-dmg": "^7.8.1",
|
||||
|
||||
@ -382,6 +382,7 @@ export class CheatingDaddyApp extends LitElement {
|
||||
_storageLoaded: { state: true },
|
||||
_updateAvailable: { state: true },
|
||||
_whisperDownloading: { state: true },
|
||||
_whisperProgress: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
@ -407,6 +408,7 @@ export class CheatingDaddyApp extends LitElement {
|
||||
this._timerInterval = null;
|
||||
this._updateAvailable = false;
|
||||
this._whisperDownloading = false;
|
||||
this._whisperProgress = null;
|
||||
this._localVersion = "";
|
||||
|
||||
this._loadFromStorage();
|
||||
@ -485,6 +487,10 @@ export class CheatingDaddyApp extends LitElement {
|
||||
);
|
||||
ipcRenderer.on("whisper-downloading", (_, downloading) => {
|
||||
this._whisperDownloading = downloading;
|
||||
if (!downloading) this._whisperProgress = null;
|
||||
});
|
||||
ipcRenderer.on("whisper-progress", (_, progress) => {
|
||||
this._whisperProgress = progress;
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -500,6 +506,7 @@ export class CheatingDaddyApp extends LitElement {
|
||||
ipcRenderer.removeAllListeners("click-through-toggled");
|
||||
ipcRenderer.removeAllListeners("reconnect-failed");
|
||||
ipcRenderer.removeAllListeners("whisper-downloading");
|
||||
ipcRenderer.removeAllListeners("whisper-progress");
|
||||
}
|
||||
}
|
||||
|
||||
@ -778,6 +785,7 @@ export class CheatingDaddyApp extends LitElement {
|
||||
.onStart=${() => this.handleStart()}
|
||||
.onExternalLink=${(url) => this.handleExternalLinkClick(url)}
|
||||
.whisperDownloading=${this._whisperDownloading}
|
||||
.whisperProgress=${this._whisperProgress}
|
||||
></main-view>
|
||||
`;
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
161
src/index.js
161
src/index.js
@ -1,11 +1,43 @@
|
||||
if (require('electron-squirrel-startup')) {
|
||||
if (require("electron-squirrel-startup")) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const { app, BrowserWindow, shell, ipcMain } = require('electron');
|
||||
const { createWindow, updateGlobalShortcuts } = require('./utils/window');
|
||||
const { setupGeminiIpcHandlers, stopMacOSAudioCapture, sendToRenderer } = require('./utils/gemini');
|
||||
const storage = require('./storage');
|
||||
// ── Global crash handlers to prevent silent process termination ──
|
||||
process.on("uncaughtException", (error) => {
|
||||
console.error("[FATAL] Uncaught exception:", error);
|
||||
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 };
|
||||
let mainWindow = null;
|
||||
@ -20,9 +52,9 @@ app.whenReady().then(async () => {
|
||||
storage.initializeStorage();
|
||||
|
||||
// Trigger screen recording permission prompt on macOS if not already granted
|
||||
if (process.platform === 'darwin') {
|
||||
const { desktopCapturer } = require('electron');
|
||||
desktopCapturer.getSources({ types: ['screen'] }).catch(() => {});
|
||||
if (process.platform === "darwin") {
|
||||
const { desktopCapturer } = require("electron");
|
||||
desktopCapturer.getSources({ types: ["screen"] }).catch(() => {});
|
||||
}
|
||||
|
||||
createMainWindow();
|
||||
@ -31,18 +63,18 @@ app.whenReady().then(async () => {
|
||||
setupGeneralIpcHandlers();
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
app.on("window-all-closed", () => {
|
||||
stopMacOSAudioCapture();
|
||||
if (process.platform !== 'darwin') {
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on('before-quit', () => {
|
||||
app.on("before-quit", () => {
|
||||
stopMacOSAudioCapture();
|
||||
});
|
||||
|
||||
app.on('activate', () => {
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createMainWindow();
|
||||
}
|
||||
@ -50,250 +82,255 @@ app.on('activate', () => {
|
||||
|
||||
function setupStorageIpcHandlers() {
|
||||
// ============ CONFIG ============
|
||||
ipcMain.handle('storage:get-config', async () => {
|
||||
ipcMain.handle("storage:get-config", async () => {
|
||||
try {
|
||||
return { success: true, data: storage.getConfig() };
|
||||
} catch (error) {
|
||||
console.error('Error getting config:', error);
|
||||
console.error("Error getting config:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('storage:set-config', async (event, config) => {
|
||||
ipcMain.handle("storage:set-config", async (event, config) => {
|
||||
try {
|
||||
storage.setConfig(config);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error setting config:', error);
|
||||
console.error("Error setting config:", error);
|
||||
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 {
|
||||
storage.updateConfig(key, value);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error updating config:', error);
|
||||
console.error("Error updating config:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
// ============ CREDENTIALS ============
|
||||
ipcMain.handle('storage:get-credentials', async () => {
|
||||
ipcMain.handle("storage:get-credentials", async () => {
|
||||
try {
|
||||
return { success: true, data: storage.getCredentials() };
|
||||
} catch (error) {
|
||||
console.error('Error getting credentials:', error);
|
||||
console.error("Error getting credentials:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('storage:set-credentials', async (event, credentials) => {
|
||||
ipcMain.handle("storage:set-credentials", async (event, credentials) => {
|
||||
try {
|
||||
storage.setCredentials(credentials);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error setting credentials:', error);
|
||||
console.error("Error setting credentials:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('storage:get-api-key', async () => {
|
||||
ipcMain.handle("storage:get-api-key", async () => {
|
||||
try {
|
||||
return { success: true, data: storage.getApiKey() };
|
||||
} catch (error) {
|
||||
console.error('Error getting API key:', error);
|
||||
console.error("Error getting API key:", error);
|
||||
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 {
|
||||
storage.setApiKey(apiKey);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error setting API key:', error);
|
||||
console.error("Error setting API key:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('storage:get-groq-api-key', async () => {
|
||||
ipcMain.handle("storage:get-groq-api-key", async () => {
|
||||
try {
|
||||
return { success: true, data: storage.getGroqApiKey() };
|
||||
} catch (error) {
|
||||
console.error('Error getting Groq API key:', error);
|
||||
console.error("Error getting Groq API key:", error);
|
||||
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 {
|
||||
storage.setGroqApiKey(groqApiKey);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error setting Groq API key:', error);
|
||||
console.error("Error setting Groq API key:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
// ============ PREFERENCES ============
|
||||
ipcMain.handle('storage:get-preferences', async () => {
|
||||
ipcMain.handle("storage:get-preferences", async () => {
|
||||
try {
|
||||
return { success: true, data: storage.getPreferences() };
|
||||
} catch (error) {
|
||||
console.error('Error getting preferences:', error);
|
||||
console.error("Error getting preferences:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('storage:set-preferences', async (event, preferences) => {
|
||||
ipcMain.handle("storage:set-preferences", async (event, preferences) => {
|
||||
try {
|
||||
storage.setPreferences(preferences);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error setting preferences:', error);
|
||||
console.error("Error setting preferences:", error);
|
||||
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 {
|
||||
storage.updatePreference(key, value);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error updating preference:', error);
|
||||
console.error("Error updating preference:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
// ============ KEYBINDS ============
|
||||
ipcMain.handle('storage:get-keybinds', async () => {
|
||||
ipcMain.handle("storage:get-keybinds", async () => {
|
||||
try {
|
||||
return { success: true, data: storage.getKeybinds() };
|
||||
} catch (error) {
|
||||
console.error('Error getting keybinds:', error);
|
||||
console.error("Error getting keybinds:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('storage:set-keybinds', async (event, keybinds) => {
|
||||
ipcMain.handle("storage:set-keybinds", async (event, keybinds) => {
|
||||
try {
|
||||
storage.setKeybinds(keybinds);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error setting keybinds:', error);
|
||||
console.error("Error setting keybinds:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
// ============ HISTORY ============
|
||||
ipcMain.handle('storage:get-all-sessions', async () => {
|
||||
ipcMain.handle("storage:get-all-sessions", async () => {
|
||||
try {
|
||||
return { success: true, data: storage.getAllSessions() };
|
||||
} catch (error) {
|
||||
console.error('Error getting sessions:', error);
|
||||
console.error("Error getting sessions:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('storage:get-session', async (event, sessionId) => {
|
||||
ipcMain.handle("storage:get-session", async (event, sessionId) => {
|
||||
try {
|
||||
return { success: true, data: storage.getSession(sessionId) };
|
||||
} catch (error) {
|
||||
console.error('Error getting session:', error);
|
||||
console.error("Error getting session:", error);
|
||||
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 {
|
||||
storage.saveSession(sessionId, data);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error saving session:', error);
|
||||
console.error("Error saving session:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('storage:delete-session', async (event, sessionId) => {
|
||||
ipcMain.handle("storage:delete-session", async (event, sessionId) => {
|
||||
try {
|
||||
storage.deleteSession(sessionId);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error deleting session:', error);
|
||||
console.error("Error deleting session:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('storage:delete-all-sessions', async () => {
|
||||
ipcMain.handle("storage:delete-all-sessions", async () => {
|
||||
try {
|
||||
storage.deleteAllSessions();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error deleting all sessions:', error);
|
||||
console.error("Error deleting all sessions:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
// ============ LIMITS ============
|
||||
ipcMain.handle('storage:get-today-limits', async () => {
|
||||
ipcMain.handle("storage:get-today-limits", async () => {
|
||||
try {
|
||||
return { success: true, data: storage.getTodayLimits() };
|
||||
} catch (error) {
|
||||
console.error('Error getting today limits:', error);
|
||||
console.error("Error getting today limits:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
// ============ CLEAR ALL ============
|
||||
ipcMain.handle('storage:clear-all', async () => {
|
||||
ipcMain.handle("storage:clear-all", async () => {
|
||||
try {
|
||||
storage.clearAllData();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error clearing all data:', error);
|
||||
console.error("Error clearing all data:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setupGeneralIpcHandlers() {
|
||||
ipcMain.handle('get-app-version', async () => {
|
||||
ipcMain.handle("get-app-version", async () => {
|
||||
return app.getVersion();
|
||||
});
|
||||
|
||||
ipcMain.handle('quit-application', async event => {
|
||||
ipcMain.handle("quit-application", async (event) => {
|
||||
try {
|
||||
stopMacOSAudioCapture();
|
||||
app.quit();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error quitting application:', error);
|
||||
console.error("Error quitting application:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('open-external', async (event, url) => {
|
||||
ipcMain.handle("open-external", async (event, url) => {
|
||||
try {
|
||||
await shell.openExternal(url);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error opening external URL:', error);
|
||||
console.error("Error opening external URL:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on('update-keybinds', (event, newKeybinds) => {
|
||||
ipcMain.on("update-keybinds", (event, newKeybinds) => {
|
||||
if (mainWindow) {
|
||||
// Also save to storage
|
||||
storage.setKeybinds(newKeybinds);
|
||||
updateGlobalShortcuts(newKeybinds, mainWindow, sendToRenderer, geminiSessionRef);
|
||||
updateGlobalShortcuts(
|
||||
newKeybinds,
|
||||
mainWindow,
|
||||
sendToRenderer,
|
||||
geminiSessionRef,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Debug logging from renderer
|
||||
ipcMain.on('log-message', (event, msg) => {
|
||||
ipcMain.on("log-message", (event, msg) => {
|
||||
console.log(msg);
|
||||
});
|
||||
}
|
||||
|
||||
200
src/storage.js
200
src/storage.js
@ -1,6 +1,6 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const os = require("os");
|
||||
|
||||
const CONFIG_VERSION = 1;
|
||||
|
||||
@ -8,38 +8,39 @@ const CONFIG_VERSION = 1;
|
||||
const DEFAULT_CONFIG = {
|
||||
configVersion: CONFIG_VERSION,
|
||||
onboarded: false,
|
||||
layout: 'normal'
|
||||
layout: "normal",
|
||||
};
|
||||
|
||||
const DEFAULT_CREDENTIALS = {
|
||||
apiKey: '',
|
||||
groqApiKey: '',
|
||||
openaiCompatibleApiKey: '',
|
||||
openaiCompatibleBaseUrl: '',
|
||||
openaiCompatibleModel: ''
|
||||
apiKey: "",
|
||||
groqApiKey: "",
|
||||
openaiCompatibleApiKey: "",
|
||||
openaiCompatibleBaseUrl: "",
|
||||
openaiCompatibleModel: "",
|
||||
};
|
||||
|
||||
const DEFAULT_PREFERENCES = {
|
||||
customPrompt: '',
|
||||
selectedProfile: 'interview',
|
||||
selectedLanguage: 'en-US',
|
||||
selectedScreenshotInterval: '5',
|
||||
selectedImageQuality: 'medium',
|
||||
customPrompt: "",
|
||||
selectedProfile: "interview",
|
||||
selectedLanguage: "en-US",
|
||||
selectedScreenshotInterval: "5",
|
||||
selectedImageQuality: "medium",
|
||||
advancedMode: false,
|
||||
audioMode: 'speaker_only',
|
||||
fontSize: 'medium',
|
||||
audioMode: "speaker_only",
|
||||
fontSize: "medium",
|
||||
backgroundTransparency: 0.8,
|
||||
googleSearchEnabled: false,
|
||||
responseProvider: 'gemini',
|
||||
ollamaHost: 'http://127.0.0.1:11434',
|
||||
ollamaModel: 'llama3.1',
|
||||
whisperModel: 'Xenova/whisper-small',
|
||||
responseProvider: "gemini",
|
||||
ollamaHost: "http://127.0.0.1:11434",
|
||||
ollamaModel: "llama3.1",
|
||||
whisperModel: "Xenova/whisper-small",
|
||||
whisperDevice: "", // '' = auto-detect, 'cpu' = native, 'wasm' = compatible
|
||||
};
|
||||
|
||||
const DEFAULT_KEYBINDS = null; // null means use system defaults
|
||||
|
||||
const DEFAULT_LIMITS = {
|
||||
data: [] // Array of { date: 'YYYY-MM-DD', flash: { count }, flashLite: { count }, groq: { 'qwen3-32b': { chars, limit }, 'gpt-oss-120b': { chars, limit }, 'gpt-oss-20b': { chars, limit } }, gemini: { 'gemma-3-27b-it': { chars } } }
|
||||
data: [], // Array of { date: 'YYYY-MM-DD', flash: { count }, flashLite: { count }, groq: { 'qwen3-32b': { chars, limit }, 'gpt-oss-120b': { chars, limit }, 'gpt-oss-20b': { chars, limit } }, gemini: { 'gemma-3-27b-it': { chars } } }
|
||||
};
|
||||
|
||||
// Get the config directory path based on OS
|
||||
@ -47,12 +48,22 @@ function getConfigDir() {
|
||||
const platform = os.platform();
|
||||
let configDir;
|
||||
|
||||
if (platform === 'win32') {
|
||||
configDir = path.join(os.homedir(), 'AppData', 'Roaming', 'cheating-daddy-config');
|
||||
} else if (platform === 'darwin') {
|
||||
configDir = path.join(os.homedir(), 'Library', 'Application Support', 'cheating-daddy-config');
|
||||
if (platform === "win32") {
|
||||
configDir = path.join(
|
||||
os.homedir(),
|
||||
"AppData",
|
||||
"Roaming",
|
||||
"cheating-daddy-config",
|
||||
);
|
||||
} else if (platform === "darwin") {
|
||||
configDir = path.join(
|
||||
os.homedir(),
|
||||
"Library",
|
||||
"Application Support",
|
||||
"cheating-daddy-config",
|
||||
);
|
||||
} else {
|
||||
configDir = path.join(os.homedir(), '.config', 'cheating-daddy-config');
|
||||
configDir = path.join(os.homedir(), ".config", "cheating-daddy-config");
|
||||
}
|
||||
|
||||
return configDir;
|
||||
@ -60,34 +71,34 @@ function getConfigDir() {
|
||||
|
||||
// File paths
|
||||
function getConfigPath() {
|
||||
return path.join(getConfigDir(), 'config.json');
|
||||
return path.join(getConfigDir(), "config.json");
|
||||
}
|
||||
|
||||
function getCredentialsPath() {
|
||||
return path.join(getConfigDir(), 'credentials.json');
|
||||
return path.join(getConfigDir(), "credentials.json");
|
||||
}
|
||||
|
||||
function getPreferencesPath() {
|
||||
return path.join(getConfigDir(), 'preferences.json');
|
||||
return path.join(getConfigDir(), "preferences.json");
|
||||
}
|
||||
|
||||
function getKeybindsPath() {
|
||||
return path.join(getConfigDir(), 'keybinds.json');
|
||||
return path.join(getConfigDir(), "keybinds.json");
|
||||
}
|
||||
|
||||
function getLimitsPath() {
|
||||
return path.join(getConfigDir(), 'limits.json');
|
||||
return path.join(getConfigDir(), "limits.json");
|
||||
}
|
||||
|
||||
function getHistoryDir() {
|
||||
return path.join(getConfigDir(), 'history');
|
||||
return path.join(getConfigDir(), "history");
|
||||
}
|
||||
|
||||
// Helper to read JSON file safely
|
||||
function readJsonFile(filePath, defaultValue) {
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
const data = fs.readFileSync(filePath, 'utf8');
|
||||
const data = fs.readFileSync(filePath, "utf8");
|
||||
return JSON.parse(data);
|
||||
}
|
||||
} catch (error) {
|
||||
@ -103,7 +114,7 @@ function writeJsonFile(filePath, data) {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
|
||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf8");
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Error writing ${filePath}:`, error.message);
|
||||
@ -119,7 +130,7 @@ function needsReset() {
|
||||
}
|
||||
|
||||
try {
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
||||
return !config.configVersion || config.configVersion !== CONFIG_VERSION;
|
||||
} catch {
|
||||
return true;
|
||||
@ -130,7 +141,7 @@ function needsReset() {
|
||||
function resetConfigDir() {
|
||||
const configDir = getConfigDir();
|
||||
|
||||
console.log('Resetting config directory...');
|
||||
console.log("Resetting config directory...");
|
||||
|
||||
// Remove existing directory if it exists
|
||||
if (fs.existsSync(configDir)) {
|
||||
@ -146,7 +157,7 @@ function resetConfigDir() {
|
||||
writeJsonFile(getCredentialsPath(), DEFAULT_CREDENTIALS);
|
||||
writeJsonFile(getPreferencesPath(), DEFAULT_PREFERENCES);
|
||||
|
||||
console.log('Config directory initialized with defaults');
|
||||
console.log("Config directory initialized with defaults");
|
||||
}
|
||||
|
||||
// Initialize storage - call this on app startup
|
||||
@ -193,7 +204,7 @@ function setCredentials(credentials) {
|
||||
}
|
||||
|
||||
function getApiKey() {
|
||||
return getCredentials().apiKey || '';
|
||||
return getCredentials().apiKey || "";
|
||||
}
|
||||
|
||||
function setApiKey(apiKey) {
|
||||
@ -201,7 +212,7 @@ function setApiKey(apiKey) {
|
||||
}
|
||||
|
||||
function getGroqApiKey() {
|
||||
return getCredentials().groqApiKey || '';
|
||||
return getCredentials().groqApiKey || "";
|
||||
}
|
||||
|
||||
function setGroqApiKey(groqApiKey) {
|
||||
@ -211,9 +222,9 @@ function setGroqApiKey(groqApiKey) {
|
||||
function getOpenAICompatibleConfig() {
|
||||
const creds = getCredentials();
|
||||
return {
|
||||
apiKey: creds.openaiCompatibleApiKey || '',
|
||||
baseUrl: creds.openaiCompatibleBaseUrl || '',
|
||||
model: creds.openaiCompatibleModel || ''
|
||||
apiKey: creds.openaiCompatibleApiKey || "",
|
||||
baseUrl: creds.openaiCompatibleBaseUrl || "",
|
||||
model: creds.openaiCompatibleModel || "",
|
||||
};
|
||||
}
|
||||
|
||||
@ -221,7 +232,7 @@ function setOpenAICompatibleConfig(apiKey, baseUrl, model) {
|
||||
return setCredentials({
|
||||
openaiCompatibleApiKey: apiKey,
|
||||
openaiCompatibleBaseUrl: baseUrl,
|
||||
openaiCompatibleModel: model
|
||||
openaiCompatibleModel: model,
|
||||
});
|
||||
}
|
||||
|
||||
@ -266,7 +277,7 @@ function setLimits(limits) {
|
||||
|
||||
function getTodayDateString() {
|
||||
const now = new Date();
|
||||
return now.toISOString().split('T')[0]; // YYYY-MM-DD
|
||||
return now.toISOString().split("T")[0]; // YYYY-MM-DD
|
||||
}
|
||||
|
||||
function getTodayLimits() {
|
||||
@ -274,21 +285,21 @@ function getTodayLimits() {
|
||||
const today = getTodayDateString();
|
||||
|
||||
// Find today's entry
|
||||
const todayEntry = limits.data.find(entry => entry.date === today);
|
||||
const todayEntry = limits.data.find((entry) => entry.date === today);
|
||||
|
||||
if (todayEntry) {
|
||||
// ensure new fields exist
|
||||
if (!todayEntry.groq) {
|
||||
todayEntry.groq = {
|
||||
'qwen3-32b': { chars: 0, limit: 1500000 },
|
||||
'gpt-oss-120b': { chars: 0, limit: 600000 },
|
||||
'gpt-oss-20b': { chars: 0, limit: 600000 },
|
||||
'kimi-k2-instruct': { chars: 0, limit: 600000 }
|
||||
"qwen3-32b": { chars: 0, limit: 1500000 },
|
||||
"gpt-oss-120b": { chars: 0, limit: 600000 },
|
||||
"gpt-oss-20b": { chars: 0, limit: 600000 },
|
||||
"kimi-k2-instruct": { chars: 0, limit: 600000 },
|
||||
};
|
||||
}
|
||||
if (!todayEntry.gemini) {
|
||||
todayEntry.gemini = {
|
||||
'gemma-3-27b-it': { chars: 0 }
|
||||
"gemma-3-27b-it": { chars: 0 },
|
||||
};
|
||||
}
|
||||
setLimits(limits);
|
||||
@ -296,20 +307,20 @@ function getTodayLimits() {
|
||||
}
|
||||
|
||||
// No entry for today - clean old entries and create new one
|
||||
limits.data = limits.data.filter(entry => entry.date === today);
|
||||
limits.data = limits.data.filter((entry) => entry.date === today);
|
||||
const newEntry = {
|
||||
date: today,
|
||||
flash: { count: 0 },
|
||||
flashLite: { count: 0 },
|
||||
groq: {
|
||||
'qwen3-32b': { chars: 0, limit: 1500000 },
|
||||
'gpt-oss-120b': { chars: 0, limit: 600000 },
|
||||
'gpt-oss-20b': { chars: 0, limit: 600000 },
|
||||
'kimi-k2-instruct': { chars: 0, limit: 600000 }
|
||||
"qwen3-32b": { chars: 0, limit: 1500000 },
|
||||
"gpt-oss-120b": { chars: 0, limit: 600000 },
|
||||
"gpt-oss-20b": { chars: 0, limit: 600000 },
|
||||
"kimi-k2-instruct": { chars: 0, limit: 600000 },
|
||||
},
|
||||
gemini: {
|
||||
'gemma-3-27b-it': { chars: 0 }
|
||||
}
|
||||
"gemma-3-27b-it": { chars: 0 },
|
||||
},
|
||||
};
|
||||
limits.data.push(newEntry);
|
||||
setLimits(limits);
|
||||
@ -322,7 +333,7 @@ function incrementLimitCount(model) {
|
||||
const today = getTodayDateString();
|
||||
|
||||
// Find or create today's entry
|
||||
let todayEntry = limits.data.find(entry => entry.date === today);
|
||||
let todayEntry = limits.data.find((entry) => entry.date === today);
|
||||
|
||||
if (!todayEntry) {
|
||||
// Clean old entries and create new one
|
||||
@ -330,18 +341,18 @@ function incrementLimitCount(model) {
|
||||
todayEntry = {
|
||||
date: today,
|
||||
flash: { count: 0 },
|
||||
flashLite: { count: 0 }
|
||||
flashLite: { count: 0 },
|
||||
};
|
||||
limits.data.push(todayEntry);
|
||||
} else {
|
||||
// Clean old entries, keep only today
|
||||
limits.data = limits.data.filter(entry => entry.date === today);
|
||||
limits.data = limits.data.filter((entry) => entry.date === today);
|
||||
}
|
||||
|
||||
// Increment the appropriate model count
|
||||
if (model === 'gemini-2.5-flash') {
|
||||
if (model === "gemini-2.5-flash") {
|
||||
todayEntry.flash.count++;
|
||||
} else if (model === 'gemini-2.5-flash-lite') {
|
||||
} else if (model === "gemini-2.5-flash-lite") {
|
||||
todayEntry.flashLite.count++;
|
||||
}
|
||||
|
||||
@ -354,7 +365,7 @@ function incrementCharUsage(provider, model, charCount) {
|
||||
|
||||
const limits = getLimits();
|
||||
const today = getTodayDateString();
|
||||
const todayEntry = limits.data.find(entry => entry.date === today);
|
||||
const todayEntry = limits.data.find((entry) => entry.date === today);
|
||||
|
||||
if (todayEntry[provider] && todayEntry[provider][model]) {
|
||||
todayEntry[provider][model].chars += charCount;
|
||||
@ -370,29 +381,29 @@ function getAvailableModel() {
|
||||
// RPD limits: flash = 20, flash-lite = 20
|
||||
// After both exhausted, fall back to flash (for paid API users)
|
||||
if (todayLimits.flash.count < 20) {
|
||||
return 'gemini-2.5-flash';
|
||||
return "gemini-2.5-flash";
|
||||
} else if (todayLimits.flashLite.count < 20) {
|
||||
return 'gemini-2.5-flash-lite';
|
||||
return "gemini-2.5-flash-lite";
|
||||
}
|
||||
|
||||
return 'gemini-2.5-flash'; // Default to flash for paid API users
|
||||
return "gemini-2.5-flash"; // Default to flash for paid API users
|
||||
}
|
||||
|
||||
function getModelForToday() {
|
||||
const todayEntry = getTodayLimits();
|
||||
const groq = todayEntry.groq;
|
||||
|
||||
if (groq['qwen3-32b'].chars < groq['qwen3-32b'].limit) {
|
||||
return 'qwen/qwen3-32b';
|
||||
if (groq["qwen3-32b"].chars < groq["qwen3-32b"].limit) {
|
||||
return "qwen/qwen3-32b";
|
||||
}
|
||||
if (groq['gpt-oss-120b'].chars < groq['gpt-oss-120b'].limit) {
|
||||
return 'openai/gpt-oss-120b';
|
||||
if (groq["gpt-oss-120b"].chars < groq["gpt-oss-120b"].limit) {
|
||||
return "openai/gpt-oss-120b";
|
||||
}
|
||||
if (groq['gpt-oss-20b'].chars < groq['gpt-oss-20b'].limit) {
|
||||
return 'openai/gpt-oss-20b';
|
||||
if (groq["gpt-oss-20b"].chars < groq["gpt-oss-20b"].limit) {
|
||||
return "openai/gpt-oss-20b";
|
||||
}
|
||||
if (groq['kimi-k2-instruct'].chars < groq['kimi-k2-instruct'].limit) {
|
||||
return 'moonshotai/kimi-k2-instruct';
|
||||
if (groq["kimi-k2-instruct"].chars < groq["kimi-k2-instruct"].limit) {
|
||||
return "moonshotai/kimi-k2-instruct";
|
||||
}
|
||||
|
||||
// All limits exhausted
|
||||
@ -419,8 +430,12 @@ function saveSession(sessionId, data) {
|
||||
profile: data.profile || existingSession?.profile || null,
|
||||
customPrompt: data.customPrompt || existingSession?.customPrompt || null,
|
||||
// Conversation data
|
||||
conversationHistory: data.conversationHistory || existingSession?.conversationHistory || [],
|
||||
screenAnalysisHistory: data.screenAnalysisHistory || existingSession?.screenAnalysisHistory || []
|
||||
conversationHistory:
|
||||
data.conversationHistory || existingSession?.conversationHistory || [],
|
||||
screenAnalysisHistory:
|
||||
data.screenAnalysisHistory ||
|
||||
existingSession?.screenAnalysisHistory ||
|
||||
[],
|
||||
};
|
||||
return writeJsonFile(sessionPath, sessionData);
|
||||
}
|
||||
@ -437,17 +452,19 @@ function getAllSessions() {
|
||||
return [];
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(historyDir)
|
||||
.filter(f => f.endsWith('.json'))
|
||||
const files = fs
|
||||
.readdirSync(historyDir)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.sort((a, b) => {
|
||||
// Sort by timestamp descending (newest first)
|
||||
const tsA = parseInt(a.replace('.json', ''));
|
||||
const tsB = parseInt(b.replace('.json', ''));
|
||||
const tsA = parseInt(a.replace(".json", ""));
|
||||
const tsB = parseInt(b.replace(".json", ""));
|
||||
return tsB - tsA;
|
||||
});
|
||||
|
||||
return files.map(file => {
|
||||
const sessionId = file.replace('.json', '');
|
||||
return files
|
||||
.map((file) => {
|
||||
const sessionId = file.replace(".json", "");
|
||||
const data = readJsonFile(path.join(historyDir, file), null);
|
||||
if (data) {
|
||||
return {
|
||||
@ -457,13 +474,14 @@ function getAllSessions() {
|
||||
messageCount: data.conversationHistory?.length || 0,
|
||||
screenAnalysisCount: data.screenAnalysisHistory?.length || 0,
|
||||
profile: data.profile || null,
|
||||
customPrompt: data.customPrompt || null
|
||||
customPrompt: data.customPrompt || null,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}).filter(Boolean);
|
||||
})
|
||||
.filter(Boolean);
|
||||
} catch (error) {
|
||||
console.error('Error reading sessions:', error.message);
|
||||
console.error("Error reading sessions:", error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@ -476,7 +494,7 @@ function deleteSession(sessionId) {
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting session:', error.message);
|
||||
console.error("Error deleting session:", error.message);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@ -485,14 +503,16 @@ function deleteAllSessions() {
|
||||
const historyDir = getHistoryDir();
|
||||
try {
|
||||
if (fs.existsSync(historyDir)) {
|
||||
const files = fs.readdirSync(historyDir).filter(f => f.endsWith('.json'));
|
||||
files.forEach(file => {
|
||||
const files = fs
|
||||
.readdirSync(historyDir)
|
||||
.filter((f) => f.endsWith(".json"));
|
||||
files.forEach((file) => {
|
||||
fs.unlinkSync(path.join(historyDir, file));
|
||||
});
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error deleting all sessions:', error.message);
|
||||
console.error("Error deleting all sessions:", error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -550,5 +570,5 @@ module.exports = {
|
||||
deleteAllSessions,
|
||||
|
||||
// Clear all
|
||||
clearAllData
|
||||
clearAllData,
|
||||
};
|
||||
|
||||
@ -1,17 +1,31 @@
|
||||
const { Ollama } = require('ollama');
|
||||
const { getSystemPrompt } = require('./prompts');
|
||||
const { sendToRenderer, initializeNewSession, saveConversationTurn } = require('./gemini');
|
||||
const { Ollama } = require("ollama");
|
||||
const { getSystemPrompt } = require("./prompts");
|
||||
const {
|
||||
sendToRenderer,
|
||||
initializeNewSession,
|
||||
saveConversationTurn,
|
||||
} = require("./gemini");
|
||||
const { fork } = require("child_process");
|
||||
const path = require("path");
|
||||
const { getSystemNode } = require("./nodeDetect");
|
||||
|
||||
// ── State ──
|
||||
|
||||
let ollamaClient = null;
|
||||
let ollamaModel = null;
|
||||
let whisperPipeline = null;
|
||||
let whisperWorker = null;
|
||||
let isWhisperLoading = false;
|
||||
let whisperReady = false;
|
||||
let localConversationHistory = [];
|
||||
let currentSystemPrompt = null;
|
||||
let isLocalActive = false;
|
||||
|
||||
// Set when we intentionally kill the worker to suppress crash handling
|
||||
let whisperShuttingDown = false;
|
||||
|
||||
// Pending transcription callback (one at a time)
|
||||
let pendingTranscribe = null;
|
||||
|
||||
// VAD state
|
||||
let isSpeaking = false;
|
||||
let speechBuffers = [];
|
||||
@ -20,13 +34,32 @@ let speechFrameCount = 0;
|
||||
|
||||
// VAD configuration
|
||||
const VAD_MODES = {
|
||||
NORMAL: { energyThreshold: 0.01, speechFramesRequired: 3, silenceFramesRequired: 30 },
|
||||
LOW_BITRATE: { energyThreshold: 0.008, speechFramesRequired: 4, silenceFramesRequired: 35 },
|
||||
AGGRESSIVE: { energyThreshold: 0.015, speechFramesRequired: 2, silenceFramesRequired: 20 },
|
||||
VERY_AGGRESSIVE: { energyThreshold: 0.02, speechFramesRequired: 2, silenceFramesRequired: 15 },
|
||||
NORMAL: {
|
||||
energyThreshold: 0.01,
|
||||
speechFramesRequired: 3,
|
||||
silenceFramesRequired: 30,
|
||||
},
|
||||
LOW_BITRATE: {
|
||||
energyThreshold: 0.008,
|
||||
speechFramesRequired: 4,
|
||||
silenceFramesRequired: 35,
|
||||
},
|
||||
AGGRESSIVE: {
|
||||
energyThreshold: 0.015,
|
||||
speechFramesRequired: 2,
|
||||
silenceFramesRequired: 20,
|
||||
},
|
||||
VERY_AGGRESSIVE: {
|
||||
energyThreshold: 0.02,
|
||||
speechFramesRequired: 2,
|
||||
silenceFramesRequired: 15,
|
||||
},
|
||||
};
|
||||
let vadConfig = VAD_MODES.VERY_AGGRESSIVE;
|
||||
|
||||
// Maximum speech buffer size: ~30 seconds at 16kHz, 16-bit mono
|
||||
const MAX_SPEECH_BUFFER_BYTES = 16000 * 2 * 30; // 960,000 bytes
|
||||
|
||||
// Audio resampling buffer
|
||||
let resampleRemainder = Buffer.alloc(0);
|
||||
|
||||
@ -47,15 +80,24 @@ function resample24kTo16k(inputBuffer) {
|
||||
const frac = srcPos - srcIndex;
|
||||
|
||||
const s0 = combined.readInt16LE(srcIndex * 2);
|
||||
const s1 = srcIndex + 1 < inputSamples ? combined.readInt16LE((srcIndex + 1) * 2) : s0;
|
||||
const s1 =
|
||||
srcIndex + 1 < inputSamples
|
||||
? combined.readInt16LE((srcIndex + 1) * 2)
|
||||
: s0;
|
||||
const interpolated = Math.round(s0 + frac * (s1 - s0));
|
||||
outputBuffer.writeInt16LE(Math.max(-32768, Math.min(32767, interpolated)), i * 2);
|
||||
outputBuffer.writeInt16LE(
|
||||
Math.max(-32768, Math.min(32767, interpolated)),
|
||||
i * 2,
|
||||
);
|
||||
}
|
||||
|
||||
// Store remainder for next call
|
||||
const consumedInputSamples = Math.ceil((outputSamples * 3) / 2);
|
||||
const remainderStart = consumedInputSamples * 2;
|
||||
resampleRemainder = remainderStart < combined.length ? combined.slice(remainderStart) : Buffer.alloc(0);
|
||||
resampleRemainder =
|
||||
remainderStart < combined.length
|
||||
? combined.slice(remainderStart)
|
||||
: Buffer.alloc(0);
|
||||
|
||||
return outputBuffer;
|
||||
}
|
||||
@ -84,8 +126,8 @@ function processVAD(pcm16kBuffer) {
|
||||
if (!isSpeaking && speechFrameCount >= vadConfig.speechFramesRequired) {
|
||||
isSpeaking = true;
|
||||
speechBuffers = [];
|
||||
console.log('[LocalAI] Speech started (RMS:', rms.toFixed(4), ')');
|
||||
sendToRenderer('update-status', 'Listening... (speech detected)');
|
||||
console.log("[LocalAI] Speech started (RMS:", rms.toFixed(4), ")");
|
||||
sendToRenderer("update-status", "Listening... (speech detected)");
|
||||
}
|
||||
} else {
|
||||
silenceFrameCount++;
|
||||
@ -93,13 +135,23 @@ function processVAD(pcm16kBuffer) {
|
||||
|
||||
if (isSpeaking && silenceFrameCount >= vadConfig.silenceFramesRequired) {
|
||||
isSpeaking = false;
|
||||
console.log('[LocalAI] Speech ended, accumulated', speechBuffers.length, 'chunks');
|
||||
sendToRenderer('update-status', 'Transcribing...');
|
||||
console.log(
|
||||
"[LocalAI] Speech ended, accumulated",
|
||||
speechBuffers.length,
|
||||
"chunks",
|
||||
);
|
||||
sendToRenderer("update-status", "Transcribing...");
|
||||
|
||||
// Trigger transcription with accumulated audio
|
||||
const audioData = Buffer.concat(speechBuffers);
|
||||
speechBuffers = [];
|
||||
handleSpeechEnd(audioData);
|
||||
handleSpeechEnd(audioData).catch((err) => {
|
||||
console.error("[LocalAI] handleSpeechEnd crashed:", err);
|
||||
sendToRenderer(
|
||||
"update-status",
|
||||
"Transcription error: " + (err?.message || "unknown"),
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
@ -107,76 +159,395 @@ function processVAD(pcm16kBuffer) {
|
||||
// Accumulate audio during speech
|
||||
if (isSpeaking) {
|
||||
speechBuffers.push(Buffer.from(pcm16kBuffer));
|
||||
|
||||
// Cap buffer at ~30 seconds to prevent OOM and ONNX tensor overflow
|
||||
const totalBytes = speechBuffers.reduce((sum, b) => sum + b.length, 0);
|
||||
if (totalBytes >= MAX_SPEECH_BUFFER_BYTES) {
|
||||
isSpeaking = false;
|
||||
console.log(
|
||||
"[LocalAI] Speech buffer limit reached (" +
|
||||
totalBytes +
|
||||
" bytes), forcing transcription",
|
||||
);
|
||||
sendToRenderer("update-status", "Transcribing (max length reached)...");
|
||||
const audioData = Buffer.concat(speechBuffers);
|
||||
speechBuffers = [];
|
||||
silenceFrameCount = 0;
|
||||
speechFrameCount = 0;
|
||||
handleSpeechEnd(audioData).catch((err) => {
|
||||
console.error("[LocalAI] handleSpeechEnd crashed:", err);
|
||||
sendToRenderer(
|
||||
"update-status",
|
||||
"Transcription error: " + (err?.message || "unknown"),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Whisper Transcription ──
|
||||
// ── Whisper Worker (isolated child process) ──
|
||||
|
||||
function spawnWhisperWorker() {
|
||||
if (whisperWorker) return;
|
||||
|
||||
const workerPath = path.join(__dirname, "whisperWorker.js");
|
||||
console.log("[LocalAI] Spawning Whisper worker:", workerPath);
|
||||
|
||||
// Determine the best way to spawn the worker:
|
||||
// 1. System Node.js (preferred) — native addons were compiled against this
|
||||
// ABI, so onnxruntime-node works without SIGTRAP / ABI mismatches.
|
||||
// 2. Electron utilityProcess (packaged builds) — proper Node.js child
|
||||
// process API that doesn't require the RunAsNode fuse.
|
||||
// 3. ELECTRON_RUN_AS_NODE (last resort, dev only) — the old approach that
|
||||
// only works when the RunAsNode fuse isn't flipped.
|
||||
|
||||
const systemNode = getSystemNode();
|
||||
|
||||
if (systemNode) {
|
||||
// Spawn with system Node.js — onnxruntime-node native binary matches ABI
|
||||
console.log("[LocalAI] Using system Node.js:", systemNode.nodePath);
|
||||
whisperWorker = fork(workerPath, [], {
|
||||
stdio: ["pipe", "pipe", "pipe", "ipc"],
|
||||
execPath: systemNode.nodePath,
|
||||
env: {
|
||||
...process.env,
|
||||
// Unset ELECTRON_RUN_AS_NODE so the system node doesn't inherit it
|
||||
ELECTRON_RUN_AS_NODE: undefined,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// No system Node.js found — try utilityProcess (Electron >= 22)
|
||||
// utilityProcess.fork() creates a proper child Node.js process without
|
||||
// needing the RunAsNode fuse. Falls back to ELECTRON_RUN_AS_NODE for
|
||||
// dev mode where fuses aren't applied.
|
||||
try {
|
||||
const { utilityProcess: UP } = require("electron");
|
||||
if (UP && typeof UP.fork === "function") {
|
||||
console.log("[LocalAI] Using Electron utilityProcess");
|
||||
const up = UP.fork(workerPath);
|
||||
// Wrap utilityProcess to look like a ChildProcess for the rest of localai.js
|
||||
whisperWorker = wrapUtilityProcess(up);
|
||||
return;
|
||||
}
|
||||
} catch (_) {
|
||||
// utilityProcess not available (older Electron or renderer context)
|
||||
}
|
||||
|
||||
console.warn(
|
||||
"[LocalAI] No system Node.js — falling back to ELECTRON_RUN_AS_NODE (WASM backend will be used)",
|
||||
);
|
||||
whisperWorker = fork(workerPath, [], {
|
||||
stdio: ["pipe", "pipe", "pipe", "ipc"],
|
||||
env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" },
|
||||
});
|
||||
}
|
||||
|
||||
whisperWorker.stdout.on("data", (data) => {
|
||||
console.log("[WhisperWorker stdout]", data.toString().trim());
|
||||
});
|
||||
whisperWorker.stderr.on("data", (data) => {
|
||||
console.error("[WhisperWorker stderr]", data.toString().trim());
|
||||
});
|
||||
|
||||
whisperWorker.on("message", (msg) => {
|
||||
switch (msg.type) {
|
||||
case "ready":
|
||||
console.log("[LocalAI] Whisper worker ready");
|
||||
break;
|
||||
case "load-result":
|
||||
handleWorkerLoadResult(msg);
|
||||
break;
|
||||
case "transcribe-result":
|
||||
handleWorkerTranscribeResult(msg);
|
||||
break;
|
||||
case "status":
|
||||
sendToRenderer("update-status", msg.message);
|
||||
break;
|
||||
case "progress":
|
||||
sendToRenderer("whisper-progress", {
|
||||
file: msg.file,
|
||||
progress: msg.progress,
|
||||
loaded: msg.loaded,
|
||||
total: msg.total,
|
||||
status: msg.status,
|
||||
});
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
whisperWorker.on("exit", (code, signal) => {
|
||||
console.error(
|
||||
"[LocalAI] Whisper worker exited — code:",
|
||||
code,
|
||||
"signal:",
|
||||
signal,
|
||||
);
|
||||
whisperWorker = null;
|
||||
whisperReady = false;
|
||||
|
||||
// If we intentionally shut down, don't treat as crash
|
||||
if (whisperShuttingDown) {
|
||||
whisperShuttingDown = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Reject any pending transcription
|
||||
if (pendingTranscribe) {
|
||||
pendingTranscribe.reject(
|
||||
new Error(
|
||||
"Whisper worker crashed (code: " + code + ", signal: " + signal + ")",
|
||||
),
|
||||
);
|
||||
pendingTranscribe = null;
|
||||
}
|
||||
|
||||
// If session is still active, inform the user and respawn
|
||||
if (isLocalActive) {
|
||||
sendToRenderer(
|
||||
"update-status",
|
||||
"Whisper crashed (signal: " +
|
||||
(signal || code) +
|
||||
"). Respawning worker...",
|
||||
);
|
||||
setTimeout(() => {
|
||||
if (isLocalActive) {
|
||||
respawnWhisperWorker();
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
});
|
||||
|
||||
whisperWorker.on("error", (err) => {
|
||||
console.error("[LocalAI] Whisper worker error:", err);
|
||||
whisperWorker = null;
|
||||
whisperReady = false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap Electron's utilityProcess to behave like a ChildProcess (duck-typing)
|
||||
* so the rest of localai.js can use the same API.
|
||||
*/
|
||||
function wrapUtilityProcess(up) {
|
||||
const EventEmitter = require("events");
|
||||
const wrapper = new EventEmitter();
|
||||
|
||||
// Forward messages
|
||||
up.on("message", (msg) => wrapper.emit("message", msg));
|
||||
|
||||
// Map utilityProcess exit to ChildProcess-like exit event
|
||||
up.on("exit", (code) => wrapper.emit("exit", code, null));
|
||||
|
||||
// Provide stdout/stderr stubs (utilityProcess pipes to parent console)
|
||||
const { Readable } = require("stream");
|
||||
wrapper.stdout = new Readable({ read() {} });
|
||||
wrapper.stderr = new Readable({ read() {} });
|
||||
|
||||
wrapper.send = (data) => up.postMessage(data);
|
||||
wrapper.kill = (signal) => up.kill();
|
||||
wrapper.removeAllListeners = () => {
|
||||
up.removeAllListeners();
|
||||
EventEmitter.prototype.removeAllListeners.call(wrapper);
|
||||
};
|
||||
|
||||
// Setup stdout/stderr forwarding
|
||||
wrapper.stdout.on("data", (data) => {
|
||||
console.log("[WhisperWorker stdout]", data.toString().trim());
|
||||
});
|
||||
wrapper.stderr.on("data", (data) => {
|
||||
console.error("[WhisperWorker stderr]", data.toString().trim());
|
||||
});
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
let pendingLoad = null;
|
||||
|
||||
function handleWorkerLoadResult(msg) {
|
||||
if (msg.success) {
|
||||
console.log(
|
||||
"[LocalAI] Whisper model loaded successfully (in worker, device:",
|
||||
msg.device || "unknown",
|
||||
")",
|
||||
);
|
||||
whisperReady = true;
|
||||
sendToRenderer("whisper-downloading", false);
|
||||
isWhisperLoading = false;
|
||||
if (pendingLoad) {
|
||||
pendingLoad.resolve(true);
|
||||
pendingLoad = null;
|
||||
}
|
||||
} else {
|
||||
console.error("[LocalAI] Whisper worker failed to load model:", msg.error);
|
||||
sendToRenderer("whisper-downloading", false);
|
||||
sendToRenderer(
|
||||
"update-status",
|
||||
"Failed to load Whisper model: " + msg.error,
|
||||
);
|
||||
isWhisperLoading = false;
|
||||
if (pendingLoad) {
|
||||
pendingLoad.resolve(false);
|
||||
pendingLoad = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleWorkerTranscribeResult(msg) {
|
||||
if (!pendingTranscribe) return;
|
||||
if (msg.success) {
|
||||
console.log("[LocalAI] Transcription:", msg.text);
|
||||
pendingTranscribe.resolve(msg.text || null);
|
||||
} else {
|
||||
console.error("[LocalAI] Worker transcription error:", msg.error);
|
||||
pendingTranscribe.resolve(null);
|
||||
}
|
||||
pendingTranscribe = null;
|
||||
}
|
||||
|
||||
function respawnWhisperWorker() {
|
||||
killWhisperWorker();
|
||||
spawnWhisperWorker();
|
||||
const { app } = require("electron");
|
||||
const cacheDir = path.join(app.getPath("userData"), "whisper-models");
|
||||
const modelName =
|
||||
require("../storage").getPreferences().whisperModel ||
|
||||
"Xenova/whisper-small";
|
||||
sendToRenderer("whisper-downloading", true);
|
||||
isWhisperLoading = true;
|
||||
const device = resolveWhisperDevice();
|
||||
whisperWorker.send({ type: "load", modelName, cacheDir, device });
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine which ONNX backend to use for Whisper inference.
|
||||
* - "cpu" → onnxruntime-node (fast, native — requires matching ABI)
|
||||
* - "wasm" → onnxruntime-web (slower but universally compatible)
|
||||
*
|
||||
* When spawned with system Node.js, native CPU backend is safe.
|
||||
* Otherwise default to WASM to prevent native crashes.
|
||||
*/
|
||||
function resolveWhisperDevice() {
|
||||
const prefs = require("../storage").getPreferences();
|
||||
if (prefs.whisperDevice) return prefs.whisperDevice;
|
||||
// Auto-detect: if we're running with system Node.js, native is safe
|
||||
const systemNode = getSystemNode();
|
||||
return systemNode ? "cpu" : "wasm";
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the app's BCP-47 language tag (e.g. "en-US", "ru-RU") to the
|
||||
* ISO 639-1 code that Whisper expects (e.g. "en", "ru").
|
||||
* Returns "auto" when the user selected auto-detect, which tells the
|
||||
* worker to let Whisper detect the language itself.
|
||||
*/
|
||||
function resolveWhisperLanguage() {
|
||||
const prefs = require("../storage").getPreferences();
|
||||
const lang = prefs.selectedLanguage || "en-US";
|
||||
if (lang === "auto") return "auto";
|
||||
// BCP-47: primary subtag is the ISO 639 code
|
||||
// Handle special case: "cmn-CN" → "zh" (Mandarin Chinese → Whisper uses "zh")
|
||||
const primary = lang.split("-")[0].toLowerCase();
|
||||
const WHISPER_LANG_MAP = {
|
||||
cmn: "zh",
|
||||
yue: "zh",
|
||||
};
|
||||
return WHISPER_LANG_MAP[primary] || primary;
|
||||
}
|
||||
|
||||
function killWhisperWorker() {
|
||||
if (whisperWorker) {
|
||||
whisperShuttingDown = true;
|
||||
try {
|
||||
whisperWorker.removeAllListeners();
|
||||
whisperWorker.kill();
|
||||
} catch (_) {
|
||||
// Already dead
|
||||
}
|
||||
whisperWorker = null;
|
||||
whisperReady = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWhisperPipeline(modelName) {
|
||||
if (whisperPipeline) return whisperPipeline;
|
||||
if (whisperReady) return true;
|
||||
if (isWhisperLoading) return null;
|
||||
|
||||
isWhisperLoading = true;
|
||||
console.log('[LocalAI] Loading Whisper model:', modelName);
|
||||
sendToRenderer('whisper-downloading', true);
|
||||
sendToRenderer('update-status', 'Loading Whisper model (first time may take a while)...');
|
||||
console.log("[LocalAI] Loading Whisper model via worker:", modelName);
|
||||
sendToRenderer("whisper-downloading", true);
|
||||
sendToRenderer(
|
||||
"update-status",
|
||||
"Loading Whisper model (first time may take a while)...",
|
||||
);
|
||||
|
||||
try {
|
||||
// Dynamic import for ESM module
|
||||
const { pipeline, env } = await import('@huggingface/transformers');
|
||||
// Cache models outside the asar archive so ONNX runtime can load them
|
||||
const { app } = require('electron');
|
||||
const path = require('path');
|
||||
env.cacheDir = path.join(app.getPath('userData'), 'whisper-models');
|
||||
whisperPipeline = await pipeline('automatic-speech-recognition', modelName, {
|
||||
dtype: 'q8',
|
||||
device: 'auto',
|
||||
spawnWhisperWorker();
|
||||
|
||||
const { app } = require("electron");
|
||||
const cacheDir = path.join(app.getPath("userData"), "whisper-models");
|
||||
|
||||
const device = resolveWhisperDevice();
|
||||
console.log("[LocalAI] Whisper device:", device);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
pendingLoad = { resolve };
|
||||
whisperWorker.send({ type: "load", modelName, cacheDir, device });
|
||||
});
|
||||
console.log('[LocalAI] Whisper model loaded successfully');
|
||||
sendToRenderer('whisper-downloading', false);
|
||||
isWhisperLoading = false;
|
||||
return whisperPipeline;
|
||||
} catch (error) {
|
||||
console.error('[LocalAI] Failed to load Whisper model:', error);
|
||||
sendToRenderer('whisper-downloading', false);
|
||||
sendToRenderer('update-status', 'Failed to load Whisper model: ' + error.message);
|
||||
isWhisperLoading = false;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function pcm16ToFloat32(pcm16Buffer) {
|
||||
const samples = pcm16Buffer.length / 2;
|
||||
const float32 = new Float32Array(samples);
|
||||
for (let i = 0; i < samples; i++) {
|
||||
float32[i] = pcm16Buffer.readInt16LE(i * 2) / 32768;
|
||||
}
|
||||
return float32;
|
||||
}
|
||||
|
||||
async function transcribeAudio(pcm16kBuffer) {
|
||||
if (!whisperPipeline) {
|
||||
console.error('[LocalAI] Whisper pipeline not loaded');
|
||||
if (!whisperReady || !whisperWorker) {
|
||||
console.error("[LocalAI] Whisper worker not ready");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!pcm16kBuffer || pcm16kBuffer.length < 2) {
|
||||
console.error("[LocalAI] Invalid audio buffer:", pcm16kBuffer?.length);
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(
|
||||
"[LocalAI] Starting transcription, audio length:",
|
||||
pcm16kBuffer.length,
|
||||
"bytes",
|
||||
);
|
||||
|
||||
// Send audio to worker as base64 (IPC serialization)
|
||||
const audioBase64 = pcm16kBuffer.toString("base64");
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// Timeout: if worker takes > 60s, assume it's stuck
|
||||
const timeout = setTimeout(() => {
|
||||
console.error("[LocalAI] Transcription timed out after 60s");
|
||||
if (pendingTranscribe) {
|
||||
pendingTranscribe = null;
|
||||
resolve(null);
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
pendingTranscribe = {
|
||||
resolve: (val) => {
|
||||
clearTimeout(timeout);
|
||||
resolve(val);
|
||||
},
|
||||
reject: (err) => {
|
||||
clearTimeout(timeout);
|
||||
reject(err);
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const float32Audio = pcm16ToFloat32(pcm16kBuffer);
|
||||
|
||||
// Whisper expects audio at 16kHz which is what we have
|
||||
const result = await whisperPipeline(float32Audio, {
|
||||
sampling_rate: 16000,
|
||||
language: 'en',
|
||||
task: 'transcribe',
|
||||
whisperWorker.send({
|
||||
type: "transcribe",
|
||||
audioBase64,
|
||||
language: resolveWhisperLanguage(),
|
||||
});
|
||||
|
||||
const text = result.text?.trim();
|
||||
console.log('[LocalAI] Transcription:', text);
|
||||
return text;
|
||||
} catch (error) {
|
||||
console.error('[LocalAI] Transcription error:', error);
|
||||
return null;
|
||||
} catch (err) {
|
||||
clearTimeout(timeout);
|
||||
pendingTranscribe = null;
|
||||
console.error("[LocalAI] Failed to send to worker:", err);
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Speech End Handler ──
|
||||
@ -186,35 +557,52 @@ async function handleSpeechEnd(audioData) {
|
||||
|
||||
// Minimum audio length check (~0.5 seconds at 16kHz, 16-bit)
|
||||
if (audioData.length < 16000) {
|
||||
console.log('[LocalAI] Audio too short, skipping');
|
||||
sendToRenderer('update-status', 'Listening...');
|
||||
console.log("[LocalAI] Audio too short, skipping");
|
||||
sendToRenderer("update-status", "Listening...");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[LocalAI] Processing audio:", audioData.length, "bytes");
|
||||
|
||||
try {
|
||||
const transcription = await transcribeAudio(audioData);
|
||||
|
||||
if (!transcription || transcription.trim() === '' || transcription.trim().length < 2) {
|
||||
console.log('[LocalAI] Empty transcription, skipping');
|
||||
sendToRenderer('update-status', 'Listening...');
|
||||
if (
|
||||
!transcription ||
|
||||
transcription.trim() === "" ||
|
||||
transcription.trim().length < 2
|
||||
) {
|
||||
console.log("[LocalAI] Empty transcription, skipping");
|
||||
sendToRenderer("update-status", "Listening...");
|
||||
return;
|
||||
}
|
||||
|
||||
sendToRenderer('update-status', 'Generating response...');
|
||||
sendToRenderer("update-status", "Generating response...");
|
||||
await sendToOllama(transcription);
|
||||
} catch (error) {
|
||||
console.error("[LocalAI] handleSpeechEnd error:", error);
|
||||
sendToRenderer(
|
||||
"update-status",
|
||||
"Error: " + (error?.message || "transcription failed"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ollama Chat ──
|
||||
|
||||
async function sendToOllama(transcription) {
|
||||
if (!ollamaClient || !ollamaModel) {
|
||||
console.error('[LocalAI] Ollama not configured');
|
||||
console.error("[LocalAI] Ollama not configured");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[LocalAI] Sending to Ollama:', transcription.substring(0, 100) + '...');
|
||||
console.log(
|
||||
"[LocalAI] Sending to Ollama:",
|
||||
transcription.substring(0, 100) + "...",
|
||||
);
|
||||
|
||||
localConversationHistory.push({
|
||||
role: 'user',
|
||||
role: "user",
|
||||
content: transcription.trim(),
|
||||
});
|
||||
|
||||
@ -225,7 +613,10 @@ async function sendToOllama(transcription) {
|
||||
|
||||
try {
|
||||
const messages = [
|
||||
{ role: 'system', content: currentSystemPrompt || 'You are a helpful assistant.' },
|
||||
{
|
||||
role: "system",
|
||||
content: currentSystemPrompt || "You are a helpful assistant.",
|
||||
},
|
||||
...localConversationHistory,
|
||||
];
|
||||
|
||||
@ -235,41 +626,52 @@ async function sendToOllama(transcription) {
|
||||
stream: true,
|
||||
});
|
||||
|
||||
let fullText = '';
|
||||
let fullText = "";
|
||||
let isFirst = true;
|
||||
|
||||
for await (const part of response) {
|
||||
const token = part.message?.content || '';
|
||||
const token = part.message?.content || "";
|
||||
if (token) {
|
||||
fullText += token;
|
||||
sendToRenderer(isFirst ? 'new-response' : 'update-response', fullText);
|
||||
sendToRenderer(isFirst ? "new-response" : "update-response", fullText);
|
||||
isFirst = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (fullText.trim()) {
|
||||
localConversationHistory.push({
|
||||
role: 'assistant',
|
||||
role: "assistant",
|
||||
content: fullText.trim(),
|
||||
});
|
||||
|
||||
saveConversationTurn(transcription, fullText);
|
||||
}
|
||||
|
||||
console.log('[LocalAI] Ollama response completed');
|
||||
sendToRenderer('update-status', 'Listening...');
|
||||
console.log("[LocalAI] Ollama response completed");
|
||||
sendToRenderer("update-status", "Listening...");
|
||||
} catch (error) {
|
||||
console.error('[LocalAI] Ollama error:', error);
|
||||
sendToRenderer('update-status', 'Ollama error: ' + error.message);
|
||||
console.error("[LocalAI] Ollama error:", error);
|
||||
sendToRenderer("update-status", "Ollama error: " + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API ──
|
||||
|
||||
async function initializeLocalSession(ollamaHost, model, whisperModel, profile, customPrompt) {
|
||||
console.log('[LocalAI] Initializing local session:', { ollamaHost, model, whisperModel, profile });
|
||||
async function initializeLocalSession(
|
||||
ollamaHost,
|
||||
model,
|
||||
whisperModel,
|
||||
profile,
|
||||
customPrompt,
|
||||
) {
|
||||
console.log("[LocalAI] Initializing local session:", {
|
||||
ollamaHost,
|
||||
model,
|
||||
whisperModel,
|
||||
profile,
|
||||
});
|
||||
|
||||
sendToRenderer('session-initializing', true);
|
||||
sendToRenderer("session-initializing", true);
|
||||
|
||||
try {
|
||||
// Setup system prompt
|
||||
@ -282,18 +684,26 @@ async function initializeLocalSession(ollamaHost, model, whisperModel, profile,
|
||||
// Test Ollama connection
|
||||
try {
|
||||
await ollamaClient.list();
|
||||
console.log('[LocalAI] Ollama connection verified');
|
||||
console.log("[LocalAI] Ollama connection verified");
|
||||
} catch (error) {
|
||||
console.error('[LocalAI] Cannot connect to Ollama at', ollamaHost, ':', error.message);
|
||||
sendToRenderer('session-initializing', false);
|
||||
sendToRenderer('update-status', 'Cannot connect to Ollama at ' + ollamaHost);
|
||||
console.error(
|
||||
"[LocalAI] Cannot connect to Ollama at",
|
||||
ollamaHost,
|
||||
":",
|
||||
error.message,
|
||||
);
|
||||
sendToRenderer("session-initializing", false);
|
||||
sendToRenderer(
|
||||
"update-status",
|
||||
"Cannot connect to Ollama at " + ollamaHost,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Load Whisper model
|
||||
const pipeline = await loadWhisperPipeline(whisperModel);
|
||||
if (!pipeline) {
|
||||
sendToRenderer('session-initializing', false);
|
||||
sendToRenderer("session-initializing", false);
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -309,15 +719,15 @@ async function initializeLocalSession(ollamaHost, model, whisperModel, profile,
|
||||
initializeNewSession(profile, customPrompt);
|
||||
|
||||
isLocalActive = true;
|
||||
sendToRenderer('session-initializing', false);
|
||||
sendToRenderer('update-status', 'Local AI ready - Listening...');
|
||||
sendToRenderer("session-initializing", false);
|
||||
sendToRenderer("update-status", "Local AI ready - Listening...");
|
||||
|
||||
console.log('[LocalAI] Session initialized successfully');
|
||||
console.log("[LocalAI] Session initialized successfully");
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[LocalAI] Initialization error:', error);
|
||||
sendToRenderer('session-initializing', false);
|
||||
sendToRenderer('update-status', 'Local AI error: ' + error.message);
|
||||
console.error("[LocalAI] Initialization error:", error);
|
||||
sendToRenderer("session-initializing", false);
|
||||
sendToRenderer("update-status", "Local AI error: " + error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -333,7 +743,7 @@ function processLocalAudio(monoChunk24k) {
|
||||
}
|
||||
|
||||
function closeLocalSession() {
|
||||
console.log('[LocalAI] Closing local session');
|
||||
console.log("[LocalAI] Closing local session");
|
||||
isLocalActive = false;
|
||||
isSpeaking = false;
|
||||
speechBuffers = [];
|
||||
@ -344,7 +754,8 @@ function closeLocalSession() {
|
||||
ollamaClient = null;
|
||||
ollamaModel = null;
|
||||
currentSystemPrompt = null;
|
||||
// Note: whisperPipeline is kept loaded to avoid reloading on next session
|
||||
// Note: whisperWorker is kept alive to avoid reloading model on next session
|
||||
// To fully clean up, call killWhisperWorker()
|
||||
}
|
||||
|
||||
function isLocalSessionActive() {
|
||||
@ -355,7 +766,7 @@ function isLocalSessionActive() {
|
||||
|
||||
async function sendLocalText(text) {
|
||||
if (!isLocalActive || !ollamaClient) {
|
||||
return { success: false, error: 'No active local session' };
|
||||
return { success: false, error: "No active local session" };
|
||||
}
|
||||
|
||||
try {
|
||||
@ -368,28 +779,31 @@ async function sendLocalText(text) {
|
||||
|
||||
async function sendLocalImage(base64Data, prompt) {
|
||||
if (!isLocalActive || !ollamaClient) {
|
||||
return { success: false, error: 'No active local session' };
|
||||
return { success: false, error: "No active local session" };
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('[LocalAI] Sending image to Ollama');
|
||||
sendToRenderer('update-status', 'Analyzing image...');
|
||||
console.log("[LocalAI] Sending image to Ollama");
|
||||
sendToRenderer("update-status", "Analyzing image...");
|
||||
|
||||
const userMessage = {
|
||||
role: 'user',
|
||||
role: "user",
|
||||
content: prompt,
|
||||
images: [base64Data],
|
||||
};
|
||||
|
||||
// Store text-only version in history
|
||||
localConversationHistory.push({ role: 'user', content: prompt });
|
||||
localConversationHistory.push({ role: "user", content: prompt });
|
||||
|
||||
if (localConversationHistory.length > 20) {
|
||||
localConversationHistory = localConversationHistory.slice(-20);
|
||||
}
|
||||
|
||||
const messages = [
|
||||
{ role: 'system', content: currentSystemPrompt || 'You are a helpful assistant.' },
|
||||
{
|
||||
role: "system",
|
||||
content: currentSystemPrompt || "You are a helpful assistant.",
|
||||
},
|
||||
...localConversationHistory.slice(0, -1),
|
||||
userMessage,
|
||||
];
|
||||
@ -400,29 +814,32 @@ async function sendLocalImage(base64Data, prompt) {
|
||||
stream: true,
|
||||
});
|
||||
|
||||
let fullText = '';
|
||||
let fullText = "";
|
||||
let isFirst = true;
|
||||
|
||||
for await (const part of response) {
|
||||
const token = part.message?.content || '';
|
||||
const token = part.message?.content || "";
|
||||
if (token) {
|
||||
fullText += token;
|
||||
sendToRenderer(isFirst ? 'new-response' : 'update-response', fullText);
|
||||
sendToRenderer(isFirst ? "new-response" : "update-response", fullText);
|
||||
isFirst = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (fullText.trim()) {
|
||||
localConversationHistory.push({ role: 'assistant', content: fullText.trim() });
|
||||
localConversationHistory.push({
|
||||
role: "assistant",
|
||||
content: fullText.trim(),
|
||||
});
|
||||
saveConversationTurn(prompt, fullText);
|
||||
}
|
||||
|
||||
console.log('[LocalAI] Image response completed');
|
||||
sendToRenderer('update-status', 'Listening...');
|
||||
console.log("[LocalAI] Image response completed");
|
||||
sendToRenderer("update-status", "Listening...");
|
||||
return { success: true, text: fullText, model: ollamaModel };
|
||||
} catch (error) {
|
||||
console.error('[LocalAI] Image error:', error);
|
||||
sendToRenderer('update-status', 'Ollama error: ' + error.message);
|
||||
console.error("[LocalAI] Image error:", error);
|
||||
sendToRenderer("update-status", "Ollama error: " + error.message);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
177
src/utils/nodeDetect.js
Normal file
177
src/utils/nodeDetect.js
Normal file
@ -0,0 +1,177 @@
|
||||
/**
|
||||
* nodeDetect.js — Locate the system Node.js binary.
|
||||
*
|
||||
* When spawning child processes that rely on native addons compiled against the
|
||||
* system Node.js ABI (e.g. onnxruntime-node), we must NOT run them inside
|
||||
* Electron's embedded Node.js runtime — the ABI mismatch causes SIGTRAP /
|
||||
* SIGSEGV crashes. This module finds the real system `node` binary so we can
|
||||
* pass it as `execPath` to `child_process.fork()`.
|
||||
*
|
||||
* Falls back to `null` when no system Node.js is found, letting the caller
|
||||
* decide on an alternative strategy (e.g. WASM backend).
|
||||
*/
|
||||
|
||||
const { execSync } = require("child_process");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const os = require("os");
|
||||
|
||||
/** Well-known Node.js install locations per platform. */
|
||||
const KNOWN_PATHS = {
|
||||
darwin: [
|
||||
"/usr/local/bin/node",
|
||||
"/opt/homebrew/bin/node", // Apple Silicon Homebrew
|
||||
path.join(os.homedir(), ".nvm/versions/node"), // nvm — needs glob
|
||||
path.join(os.homedir(), ".volta/bin/node"), // Volta
|
||||
path.join(os.homedir(), ".fnm/aliases/default/bin/node"), // fnm
|
||||
path.join(os.homedir(), ".mise/shims/node"), // mise (rtx)
|
||||
path.join(os.homedir(), ".asdf/shims/node"), // asdf
|
||||
],
|
||||
linux: [
|
||||
"/usr/bin/node",
|
||||
"/usr/local/bin/node",
|
||||
path.join(os.homedir(), ".nvm/versions/node"),
|
||||
path.join(os.homedir(), ".volta/bin/node"),
|
||||
path.join(os.homedir(), ".fnm/aliases/default/bin/node"),
|
||||
path.join(os.homedir(), ".mise/shims/node"),
|
||||
path.join(os.homedir(), ".asdf/shims/node"),
|
||||
],
|
||||
win32: [
|
||||
"C:\\Program Files\\nodejs\\node.exe",
|
||||
"C:\\Program Files (x86)\\nodejs\\node.exe",
|
||||
path.join(os.homedir(), "AppData", "Roaming", "nvm", "current", "node.exe"),
|
||||
path.join(os.homedir(), ".volta", "bin", "node.exe"),
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Find the latest nvm-installed Node.js binary on macOS / Linux.
|
||||
* Returns the path to the `node` binary or null.
|
||||
*/
|
||||
function findNvmNode() {
|
||||
const nvmDir = path.join(os.homedir(), ".nvm", "versions", "node");
|
||||
try {
|
||||
if (!fs.existsSync(nvmDir)) return null;
|
||||
const versions = fs.readdirSync(nvmDir).filter((d) => d.startsWith("v"));
|
||||
if (versions.length === 0) return null;
|
||||
// Sort semver descending (rough but sufficient)
|
||||
versions.sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));
|
||||
const nodeBin = path.join(nvmDir, versions[0], "bin", "node");
|
||||
if (fs.existsSync(nodeBin)) return nodeBin;
|
||||
} catch (_) {
|
||||
// Ignore
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to resolve `node` via the system PATH using `which` (Unix) or
|
||||
* `where` (Windows). Returns the path string or null.
|
||||
*/
|
||||
function whichNode() {
|
||||
try {
|
||||
const cmd = process.platform === "win32" ? "where node" : "which node";
|
||||
const result = execSync(cmd, {
|
||||
encoding: "utf8",
|
||||
timeout: 5000,
|
||||
env: {
|
||||
...process.env,
|
||||
// Ensure common manager shim dirs are on PATH
|
||||
PATH: [
|
||||
process.env.PATH || "",
|
||||
"/usr/local/bin",
|
||||
"/opt/homebrew/bin",
|
||||
path.join(os.homedir(), ".volta", "bin"),
|
||||
path.join(os.homedir(), ".fnm", "aliases", "default", "bin"),
|
||||
path.join(os.homedir(), ".mise", "shims"),
|
||||
path.join(os.homedir(), ".asdf", "shims"),
|
||||
].join(process.platform === "win32" ? ";" : ":"),
|
||||
},
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
// `where` on Windows may return multiple lines — take the first
|
||||
const first = result.split(/\r?\n/)[0].trim();
|
||||
if (first && fs.existsSync(first)) return first;
|
||||
} catch (_) {
|
||||
// Command failed
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a given path is a real Node.js binary (not the Electron binary
|
||||
* pretending to be Node via ELECTRON_RUN_AS_NODE).
|
||||
*/
|
||||
function isRealNode(nodePath) {
|
||||
if (!nodePath) return false;
|
||||
try {
|
||||
const out = execSync(
|
||||
`"${nodePath}" -e "process.stdout.write(String(!process.versions.electron))"`,
|
||||
{
|
||||
encoding: "utf8",
|
||||
timeout: 5000,
|
||||
env: { ...process.env, ELECTRON_RUN_AS_NODE: undefined },
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
},
|
||||
).trim();
|
||||
return out === "true";
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the system Node.js binary.
|
||||
*
|
||||
* @returns {{ nodePath: string } | null} The absolute path to system `node`,
|
||||
* or null if none found. The caller should fall back to WASM when null.
|
||||
*/
|
||||
function findSystemNode() {
|
||||
// 1. Try `which node` / `where node` first (respects user's PATH / shims)
|
||||
const fromPath = whichNode();
|
||||
if (fromPath && isRealNode(fromPath)) {
|
||||
return { nodePath: fromPath };
|
||||
}
|
||||
|
||||
// 2. Try nvm (has multiple version dirs)
|
||||
const fromNvm = findNvmNode();
|
||||
if (fromNvm && isRealNode(fromNvm)) {
|
||||
return { nodePath: fromNvm };
|
||||
}
|
||||
|
||||
// 3. Walk the well-known paths for the current platform
|
||||
const platform = process.platform;
|
||||
const candidates = KNOWN_PATHS[platform] || KNOWN_PATHS.linux;
|
||||
for (const candidate of candidates) {
|
||||
// Skip the nvm root — already handled above
|
||||
if (candidate.includes(".nvm/versions/node")) continue;
|
||||
if (fs.existsSync(candidate) && isRealNode(candidate)) {
|
||||
return { nodePath: candidate };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Cache so we only search once per process lifetime. */
|
||||
let _cached = undefined;
|
||||
|
||||
/**
|
||||
* Cached version of `findSystemNode()`.
|
||||
* @returns {{ nodePath: string } | null}
|
||||
*/
|
||||
function getSystemNode() {
|
||||
if (_cached === undefined) {
|
||||
_cached = findSystemNode();
|
||||
if (_cached) {
|
||||
console.log("[nodeDetect] Found system Node.js:", _cached.nodePath);
|
||||
} else {
|
||||
console.warn(
|
||||
"[nodeDetect] No system Node.js found — will fall back to WASM backend",
|
||||
);
|
||||
}
|
||||
}
|
||||
return _cached;
|
||||
}
|
||||
|
||||
module.exports = { findSystemNode, getSystemNode, isRealNode };
|
||||
332
src/utils/whisperWorker.js
Normal file
332
src/utils/whisperWorker.js
Normal file
@ -0,0 +1,332 @@
|
||||
/**
|
||||
* 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, device? }
|
||||
* { type: 'transcribe', audioBase64, language? } // PCM 16-bit 16kHz as base64
|
||||
* { type: 'shutdown' }
|
||||
*
|
||||
* worker → parent:
|
||||
* { type: 'load-result', success, error?, device? }
|
||||
* { type: 'transcribe-result', success, text?, error? }
|
||||
* { type: 'status', message }
|
||||
* { type: 'ready' }
|
||||
*/
|
||||
|
||||
// ── Crash handlers — report fatal errors before the process dies ──
|
||||
|
||||
process.on("uncaughtException", (err) => {
|
||||
try {
|
||||
send({
|
||||
type: "status",
|
||||
message: `[Worker] Uncaught exception: ${err.message || err}`,
|
||||
});
|
||||
console.error("[WhisperWorker] Uncaught exception:", err);
|
||||
} catch (_) {
|
||||
// Cannot communicate with parent anymore
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on("unhandledRejection", (reason) => {
|
||||
try {
|
||||
send({
|
||||
type: "status",
|
||||
message: `[Worker] Unhandled rejection: ${reason?.message || reason}`,
|
||||
});
|
||||
console.error("[WhisperWorker] Unhandled rejection:", reason);
|
||||
} catch (_) {
|
||||
// Cannot communicate with parent anymore
|
||||
}
|
||||
// Don't exit — let it be caught by the pipeline's own handlers
|
||||
});
|
||||
|
||||
let whisperPipeline = null;
|
||||
/** Which ONNX backend is actually active: "cpu" | "wasm" */
|
||||
let activeDevice = 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the Whisper model.
|
||||
*
|
||||
* @param {string} modelName HuggingFace model id, e.g. "Xenova/whisper-small"
|
||||
* @param {string} cacheDir Directory for cached model files
|
||||
* @param {string} [device] "cpu" (onnxruntime-node) or "wasm" (onnxruntime-web).
|
||||
* When "cpu" is requested we try native first and fall
|
||||
* back to "wasm" on failure (ABI mismatch, etc.).
|
||||
*/
|
||||
async function loadModel(modelName, cacheDir, device = "cpu") {
|
||||
if (whisperPipeline) {
|
||||
send({ type: "load-result", success: true, device: activeDevice });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
send({
|
||||
type: "status",
|
||||
message: "Loading Whisper model (first time may take a while)...",
|
||||
});
|
||||
|
||||
// Validate / create cache directory
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
if (cacheDir) {
|
||||
try {
|
||||
if (!fs.existsSync(cacheDir)) {
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
console.log("[WhisperWorker] Created cache directory:", cacheDir);
|
||||
}
|
||||
} catch (mkdirErr) {
|
||||
console.warn(
|
||||
"[WhisperWorker] Cannot create cache dir:",
|
||||
mkdirErr.message,
|
||||
);
|
||||
}
|
||||
|
||||
// Check for corrupted partial downloads — if an onnx file exists but
|
||||
// is suspiciously small (< 1 KB), delete it so the library re-downloads.
|
||||
try {
|
||||
const modelDir = path.join(cacheDir, modelName.replace("/", path.sep));
|
||||
if (fs.existsSync(modelDir)) {
|
||||
const walk = (dir) => {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walk(full);
|
||||
} else if (
|
||||
entry.name.endsWith(".onnx") &&
|
||||
fs.statSync(full).size < 1024
|
||||
) {
|
||||
console.warn(
|
||||
"[WhisperWorker] Removing likely-corrupt file:",
|
||||
full,
|
||||
);
|
||||
fs.unlinkSync(full);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(modelDir);
|
||||
}
|
||||
} catch (cleanErr) {
|
||||
console.warn("[WhisperWorker] Cache cleanup error:", cleanErr.message);
|
||||
}
|
||||
}
|
||||
|
||||
const { pipeline, env } = await import("@huggingface/transformers");
|
||||
env.cacheDir = cacheDir;
|
||||
|
||||
// Attempt to load with the requested device
|
||||
const devicesToTry = device === "wasm" ? ["wasm"] : ["cpu", "wasm"];
|
||||
|
||||
let lastError = null;
|
||||
|
||||
for (const dev of devicesToTry) {
|
||||
try {
|
||||
send({
|
||||
type: "status",
|
||||
message: `Loading Whisper (${dev} backend)...`,
|
||||
});
|
||||
console.log(
|
||||
`[WhisperWorker] Trying device: ${dev}, model: ${modelName}`,
|
||||
);
|
||||
|
||||
whisperPipeline = await pipeline(
|
||||
"automatic-speech-recognition",
|
||||
modelName,
|
||||
{
|
||||
dtype: "q8",
|
||||
device: dev,
|
||||
progress_callback: (progress) => {
|
||||
// progress: { status, name?, file?, progress?, loaded?, total? }
|
||||
if (
|
||||
progress.status === "download" ||
|
||||
progress.status === "progress"
|
||||
) {
|
||||
send({
|
||||
type: "progress",
|
||||
file: progress.file || progress.name || "",
|
||||
progress: progress.progress ?? 0,
|
||||
loaded: progress.loaded ?? 0,
|
||||
total: progress.total ?? 0,
|
||||
status: progress.status,
|
||||
});
|
||||
} else if (progress.status === "done") {
|
||||
send({
|
||||
type: "progress",
|
||||
file: progress.file || progress.name || "",
|
||||
progress: 100,
|
||||
loaded: progress.total ?? 0,
|
||||
total: progress.total ?? 0,
|
||||
status: "done",
|
||||
});
|
||||
} else if (progress.status === "initiate") {
|
||||
send({
|
||||
type: "progress",
|
||||
file: progress.file || progress.name || "",
|
||||
progress: 0,
|
||||
loaded: 0,
|
||||
total: 0,
|
||||
status: "initiate",
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
activeDevice = dev;
|
||||
console.log(
|
||||
`[WhisperWorker] Model loaded successfully (device: ${dev})`,
|
||||
);
|
||||
send({ type: "load-result", success: true, device: dev });
|
||||
return;
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
console.error(
|
||||
`[WhisperWorker] Failed to load with device "${dev}":`,
|
||||
err.message || err,
|
||||
);
|
||||
|
||||
if (dev === "cpu" && devicesToTry.includes("wasm")) {
|
||||
send({
|
||||
type: "status",
|
||||
message: `Native CPU backend failed (${err.message}). Trying WASM fallback...`,
|
||||
});
|
||||
}
|
||||
|
||||
// Reset pipeline state before retry
|
||||
whisperPipeline = null;
|
||||
}
|
||||
}
|
||||
|
||||
// All devices failed
|
||||
throw lastError || new Error("All ONNX backends failed");
|
||||
} catch (error) {
|
||||
send({ type: "load-result", success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
async function transcribe(audioBase64, language) {
|
||||
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;
|
||||
}
|
||||
|
||||
// Build pipeline options with the requested language
|
||||
const pipelineOpts = {
|
||||
sampling_rate: 16000,
|
||||
task: "transcribe",
|
||||
};
|
||||
if (language && language !== "auto") {
|
||||
pipelineOpts.language = language;
|
||||
}
|
||||
|
||||
const result = await whisperPipeline(float32Audio, pipelineOpts);
|
||||
|
||||
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, msg.device).catch((err) => {
|
||||
send({ type: "load-result", success: false, error: err.message });
|
||||
});
|
||||
break;
|
||||
case "transcribe":
|
||||
transcribe(msg.audioBase64, msg.language).catch((err) => {
|
||||
send({ type: "transcribe-result", success: false, error: err.message });
|
||||
});
|
||||
break;
|
||||
case "shutdown":
|
||||
// Dispose the ONNX session gracefully before exiting to avoid
|
||||
// native cleanup race conditions (SIGABRT on mutex destroy).
|
||||
(async () => {
|
||||
if (whisperPipeline) {
|
||||
try {
|
||||
if (typeof whisperPipeline.dispose === "function") {
|
||||
await whisperPipeline.dispose();
|
||||
}
|
||||
} catch (_) {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
whisperPipeline = null;
|
||||
}
|
||||
// Small delay to let native threads wind down
|
||||
setTimeout(() => process.exit(0), 200);
|
||||
})();
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Signal readiness to parent
|
||||
send({ type: "ready" });
|
||||
Loading…
x
Reference in New Issue
Block a user