Files
sarthi_lab/electron/main.js

472 lines
16 KiB
JavaScript

// @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/<type>/ (next to source, survives reloads)
* - Packaged: userData/<type>/ (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/<type>/ (same folder as models in dev)
* - Packaged: resourcesPath/<type>/ (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'),
}));