Initial commit: Sarthi Lab desktop application
This commit is contained in:
471
electron/main.js
Normal file
471
electron/main.js
Normal file
@@ -0,0 +1,471 @@
|
||||
// @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'),
|
||||
}));
|
||||
Reference in New Issue
Block a user