// @ts-check /* eslint-disable @typescript-eslint/no-var-requires */ const { app, BrowserWindow, ipcMain, shell } = require('electron'); const path = require('path'); const fs = require('fs'); // ← must be at top (used by TTS before the old require position) const https = require('https'); const { spawn } = require('child_process'); const Store = require('electron-store'); const store = new Store(); let mainWindow; function createWindow() { mainWindow = new BrowserWindow({ width: 1280, height: 800, minWidth: 900, minHeight: 600, webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false, }, titleBarStyle: 'hiddenInset', title: 'Sarthi Lab', }); if (!app.isPackaged) { mainWindow.loadURL('http://localhost:5174'); // mainWindow.webContents.openDevTools(); // Removed: DevTools no longer open by default } else { mainWindow.loadFile(path.join(__dirname, '../dist/index.html')); } mainWindow.webContents.setWindowOpenHandler(({ url }) => { shell.openExternal(url); return { action: 'deny' }; }); // F12 to open dev tools (useful for debugging in production) mainWindow.webContents.on('before-input-event', (event, input) => { if (input.key === 'F12') { mainWindow.webContents.openDevTools(); } }); } app.whenReady().then(createWindow); app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); }); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); }); // ── Path helpers ──────────────────────────────────────────────────────────── /** * Where DOWNLOADED model files are stored. * - Dev: electron// (next to source, survives reloads) * - Packaged: userData// (writable; resourcesPath is read-only on macOS) */ function getModelsPath(type) { if (app.isPackaged) { return path.join(app.getPath('userData'), 'models', type); } return path.join(__dirname, type); } /** * Where the BINARY executable lives. * - Dev: electron// (same folder as models in dev) * - Packaged: resourcesPath// (bundled via extraResources) */ function getBinaryDir(type) { if (app.isPackaged) { return path.join(process.resourcesPath, type); } return path.join(__dirname, type); } const BINARY_NAME = { piper: process.platform === 'win32' ? 'piper.exe' : 'piper', whisper: process.platform === 'win32' ? 'whisper-cli.exe' : 'whisper-cli', }; // ── IPC: Config store ──────────────────────────────────────────────────────── ipcMain.handle('config:get', (_, key) => store.get(key)); ipcMain.handle('config:set', (_, key, value) => { store.set(key, value); }); ipcMain.handle('config:delete', (_, key) => { store.delete(key); }); // ── IPC: TTS via Piper ─────────────────────────────────────────────────────── let piperProcess = null; ipcMain.handle('tts:speak', async (_, text) => { // Kill any existing speech if (piperProcess) { piperProcess.kill(); piperProcess = null; } // macOS: use built-in `say` command — no binary/model required, high quality if (process.platform === 'darwin') { return new Promise((resolve) => { piperProcess = spawn('say', [text]); piperProcess.on('close', () => { piperProcess = null; resolve(); }); piperProcess.on('error', (err) => { console.warn('say error:', err.message); piperProcess = null; resolve(); }); }); } const binaryDir = getBinaryDir('piper'); const modelsDir = getModelsPath('piper'); const piperBin = path.join(binaryDir, BINARY_NAME.piper); const modelPath = path.join(modelsDir, 'en_US-lessac-medium.onnx'); const modelConfig = path.join(modelsDir, 'en_US-lessac-medium.onnx.json'); // Verify binary and model files exist before spawning if (!fs.existsSync(piperBin) || !fs.existsSync(modelPath)) { console.warn('Piper binary or model missing — skipping TTS'); return; } return new Promise((resolve) => { if (process.platform === 'linux') { const playerProcess = spawn('aplay', ['-r', '22050', '-f', 'S16_LE', '-c', '1']); piperProcess = spawn(piperBin, [ '--model', modelPath, '--config', modelConfig, '--output-raw', ]); piperProcess.stdin.write(text); piperProcess.stdin.end(); piperProcess.stdout.pipe(playerProcess.stdin); piperProcess.on('error', (err) => { console.warn('Piper TTS error:', err.message); resolve(); }); playerProcess.on('close', () => { piperProcess = null; resolve(); }); playerProcess.on('error', (err) => { console.warn('aplay error:', err.message); resolve(); }); return; } // Windows: no native Piper audio pipeline — renderer falls back to Web Speech resolve(); }); }); ipcMain.handle('tts:stop', () => { if (piperProcess) { piperProcess.kill(); piperProcess = null; } }); // ── IPC: STT via whisper.cpp ───────────────────────────────────────────────── ipcMain.handle('stt:transcribe', async (_, audioBase64) => { const binaryDir = getBinaryDir('whisper'); const modelsDir = getModelsPath('whisper'); const whisperBin = path.join(binaryDir, BINARY_NAME.whisper); const modelPath = path.join(modelsDir, 'ggml-tiny.bin'); if (!fs.existsSync(whisperBin) || !fs.existsSync(modelPath)) { return { error: 'whisper not available', text: null }; } const audioBuffer = Buffer.from(audioBase64, 'base64'); const tmpPath = path.join(app.getPath('temp'), 'stt_input.wav'); fs.writeFileSync(tmpPath, audioBuffer); return new Promise((resolve) => { const whisperProcess = spawn(whisperBin, [ '--model', modelPath, '--file', tmpPath, '--language', 'en', '--no-prints', // suppress progress/status lines '--no-timestamps', // plain text output only ]); let output = ''; whisperProcess.stdout.on('data', (data) => { output += data.toString(); }); whisperProcess.on('close', (code) => { try { fs.unlinkSync(tmpPath); } catch {} if (code === 0) { resolve({ text: output.trim() }); } else { console.warn('whisper-cli exited with code', code); resolve({ error: 'transcription failed', text: null }); } }); whisperProcess.on('error', (err) => { console.warn('whisper-cli process error:', err.message); resolve({ error: err.message, text: null }); }); }); }); // ── IPC: Model Download Manager ────────────────────────────────────────────── const MODEL_URLS = { piper: [ { file: 'en_US-lessac-medium.onnx', url: 'https://huggingface.co/rhasspy/piper-voices/resolve/v1.0.0/en/en_US/lessac/medium/en_US-lessac-medium.onnx', size: 63_000_000, }, { file: 'en_US-lessac-medium.onnx.json', url: 'https://huggingface.co/rhasspy/piper-voices/resolve/v1.0.0/en/en_US/lessac/medium/en_US-lessac-medium.onnx.json', size: 5_000, }, ], whisper: [ { file: 'ggml-tiny.bin', url: 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.bin', size: 77_700_000, }, ], }; function ensureDir(dir) { if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); } /** * Download a single file with redirect following and per-chunk progress callback. * @param {string} url * @param {string} dest * @param {(percent: number, downloadedBytes: number, totalBytes: number) => void} [onProgress] */ function downloadFile(url, dest, onProgress) { return new Promise((resolve, reject) => { const file = fs.createWriteStream(dest); https.get(url, (response) => { if (response.statusCode === 301 || response.statusCode === 302) { file.close(); try { fs.unlinkSync(dest); } catch {} downloadFile(response.headers.location, dest, onProgress).then(resolve).catch(reject); return; } if (response.statusCode !== 200) { file.close(); try { fs.unlinkSync(dest); } catch {} reject(new Error(`HTTP ${response.statusCode}`)); return; } const totalBytes = parseInt(response.headers['content-length'] || '0', 10); let downloadedBytes = 0; response.on('data', (chunk) => { downloadedBytes += chunk.length; if (onProgress && totalBytes > 0) { onProgress(Math.round((downloadedBytes / totalBytes) * 100), downloadedBytes, totalBytes); } }); response.pipe(file); file.on('finish', () => { file.close(); resolve(); }); }).on('error', (err) => { file.close(); try { if (fs.existsSync(dest)) fs.unlinkSync(dest); } catch {} reject(err); }); }); } // ── Binary download URLs (for dev mode and Linux where binary isn't bundled) ─ const BINARY_DOWNLOAD_URLS = { piper: { 'linux-x64': 'https://github.com/rhasspy/piper/releases/download/2023.11.14-2/piper_linux_x86_64.tar.gz', 'linux-arm64': 'https://github.com/rhasspy/piper/releases/download/2023.11.14-2/piper_linux_aarch64.tar.gz', 'win32-x64': 'https://github.com/rhasspy/piper/releases/download/2023.11.14-2/piper_windows_x86_64.zip', }, // whisper: macOS binary is compiled and bundled; Linux/Windows get pre-built zip whisper: { 'linux-x64': 'https://github.com/ggml-org/whisper.cpp/releases/download/v1.8.3/whisper-blas-bin-x64.zip', 'win32-x64': 'https://github.com/ggml-org/whisper.cpp/releases/download/v1.8.3/whisper-blas-bin-win64.zip', }, }; /** * Extract a tar.gz to destDir, stripping the top-level directory. * After extraction, chmod 755 the target binary if it exists. */ function extractTarGz(tarPath, destDir, binaryName) { return new Promise((resolve, reject) => { ensureDir(destDir); const tar = spawn('tar', ['-xzf', tarPath, '-C', destDir, '--strip-components=1']); tar.on('close', (code) => { if (code !== 0) { reject(new Error(`tar exited with ${code}`)); return; } const bin = path.join(destDir, binaryName); if (fs.existsSync(bin)) { try { fs.chmodSync(bin, 0o755); } catch {} } resolve(); }); tar.on('error', reject); }); } /** * Extract a zip file to destDir. * On Windows uses PowerShell Expand-Archive, on macOS/Linux uses unzip. * After extraction, chmod 755 the target binary if it exists. */ function extractZip(zipPath, destDir, binaryName) { return new Promise((resolve, reject) => { ensureDir(destDir); let command, args; if (process.platform === 'win32') { // PowerShell Expand-Archive command = 'powershell'; args = [ '-Command', `Expand-Archive -Path "${zipPath}" -DestinationPath "${destDir}" -Force` ]; } else { // macOS/Linux unzip command = 'unzip'; args = ['-o', zipPath, '-d', destDir]; } const proc = spawn(command, args); proc.on('close', (code) => { if (code !== 0) { reject(new Error(`${command} exited with ${code}`)); return; } const bin = path.join(destDir, binaryName); if (fs.existsSync(bin)) { try { fs.chmodSync(bin, 0o755); } catch {} } resolve(); }); proc.on('error', reject); }); } function sendProgress(type, file, status, percent = 0, error = null) { if (!mainWindow) return; mainWindow.webContents.send('download:progress', { type, file, status, percent, ...(error ? { error } : {}) }); } ipcMain.handle('download:start', async (_, type) => { // macOS TTS uses built-in 'say' — nothing to download if (type === 'piper' && process.platform === 'darwin') { sendProgress(type, 'voice', 'complete', 100); return { results: [{ file: 'voice', status: 'ready', reason: 'macOS uses built-in say command' }] }; } const modelDir = getModelsPath(type); const binaryDir = getBinaryDir(type); ensureDir(modelDir); const files = MODEL_URLS[type]; if (!files) return { error: 'Unknown model type' }; // ── Step 1: Download binary if missing (packaged app has it in extraResources) ── const binaryPath = path.join(binaryDir, BINARY_NAME[type] || 'main'); const platformKey = `${process.platform}-${process.arch}`; const binaryUrl = BINARY_DOWNLOAD_URLS[type]?.[platformKey]; if (!fs.existsSync(binaryPath) && binaryUrl) { // Determine file extension from URL const isZip = binaryUrl.endsWith('.zip'); const ext = isZip ? '.zip' : '.tar.gz'; const tmpFile = path.join(app.getPath('temp'), `${type}_binary${ext}`); sendProgress(type, 'runtime files', 'downloading', 0); try { await downloadFile(binaryUrl, tmpFile, (percent) => { sendProgress(type, 'runtime files', 'downloading', percent); }); if (isZip) { await extractZip(tmpFile, binaryDir, BINARY_NAME[type] || 'main'); } else { await extractTarGz(tmpFile, binaryDir, BINARY_NAME[type] || 'main'); } try { fs.unlinkSync(tmpFile); } catch {} sendProgress(type, 'runtime files', 'complete', 100); } catch (err) { try { if (fs.existsSync(tmpFile)) fs.unlinkSync(tmpFile); } catch {} sendProgress(type, 'runtime files', 'error', 0, err.message); return { error: `Binary download failed: ${err.message}` }; } } // ── Step 2: Download model files ───────────────────────────────────────────── const results = []; for (const { file, url } of files) { const dest = path.join(modelDir, file); if (fs.existsSync(dest)) { results.push({ file, status: 'skipped', reason: 'already exists' }); sendProgress(type, file, 'exists', 100); continue; } sendProgress(type, file, 'downloading', 0); try { await downloadFile(url, dest, (percent) => { sendProgress(type, file, 'downloading', percent); }); results.push({ file, status: 'complete' }); sendProgress(type, file, 'complete', 100); } catch (err) { results.push({ file, status: 'error', error: err.message }); sendProgress(type, file, 'error', 0, err.message); } } return { results }; }); function checkModelStatus(type) { // macOS TTS: always available via built-in 'say' command — no model or binary needed if (type === 'piper' && process.platform === 'darwin') { return { available: true, modelsDownloaded: true, binaryPresent: true, usesSay: true }; } const modelDir = getModelsPath(type); const binaryDir = getBinaryDir(type); const files = MODEL_URLS[type] || []; const modelsDownloaded = fs.existsSync(modelDir) && files.every(f => fs.existsSync(path.join(modelDir, f.file))); const binaryPresent = fs.existsSync(path.join(binaryDir, BINARY_NAME[type] || 'main')); return { available: modelsDownloaded && binaryPresent, modelsDownloaded, binaryPresent, }; } ipcMain.handle('download:check', (_, type) => checkModelStatus(type)); ipcMain.handle('download:status', () => ({ piper: checkModelStatus('piper'), whisper: checkModelStatus('whisper'), }));