472 lines
16 KiB
JavaScript
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'),
|
|
}));
|