// Soundfont manager — tracks the active soundfont quality ('default' = bundled // GeneralUser GS, 'high' = downloaded FluidR3_GM), downloads the high-quality // soundfont to the user data dir on demand, and exposes IPC for the renderer. // // Persistence lives in /slopsmith-desktop.json. The active // SLOPSMITH_SOUNDFONT env var is chosen in python.ts at spawn time by reading // the same config, so flipping quality requires a Python restart. import { app, BrowserWindow, ipcMain } from 'electron'; import * as fs from 'fs'; import * as http from 'http'; import * as https from 'https'; import * as path from 'path'; import * as crypto from 'crypto'; import { restartPython } from './python'; // ── Source of truth for the high-quality soundfont ────────────────────────── // Public mirror of FluidR3_GM.sf2 on the feedback-soundfonts repo. When a new // soundfonts release ships, bump both constants together; a mismatch fails // the download fast with a clear error rather than serving silently-wrong // bytes. Source + licence info live on the release page itself. const SOUNDFONT_URL = 'https://github.com/got-feedback/feedBack-soundfonts/releases/download/soundfonts-v1/FluidR3_GM.sf2'; const SOUNDFONT_SHA256 = '74594e8f4250680adf590507a306655a299935343583256f3b722c48a1bc1cb0'; const SOUNDFONT_EXPECTED_SIZE_MB = 142; // ── Config + path helpers ─────────────────────────────────────────────────── export type SoundfontQuality = 'default' | 'high'; interface DesktopConfig { soundfontQuality?: SoundfontQuality; // When true, the Python backend binds 0.0.0.0 (LAN-reachable) instead of // 127.0.0.1, so other devices on the network can reach the library / sync // room. Opt-in (default loopback) — see python.ts and issue #441. lanAccess?: boolean; } function configPath(): string { return path.join(app.getPath('userData'), 'slopsmith-desktop.json'); } export function getDesktopConfig(): DesktopConfig { try { return JSON.parse(fs.readFileSync(configPath(), 'utf-8')) as DesktopConfig; } catch { return {}; } } export function setDesktopConfig(patch: Partial): void { const current = getDesktopConfig(); const next = { ...current, ...patch }; const tmp = configPath() + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(next, null, 2)); fs.renameSync(tmp, configPath()); } function soundfontsDir(): string { const dir = path.join(app.getPath('userData'), 'soundfonts'); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); return dir; } function highQualityPath(): string { return path.join(soundfontsDir(), 'FluidR3_GM.sf2'); } /** * Returns the absolute path that `python.ts` should export as * `SLOPSMITH_SOUNDFONT`, or `null` to let the core's `_find_soundfont()` * fall through to the bundled GeneralUser GS in `RESOURCESPATH/soundfonts/`. */ export function getActiveSoundfontPath(): string | null { const cfg = getDesktopConfig(); if (cfg.soundfontQuality === 'high' && fs.existsSync(highQualityPath())) { return highQualityPath(); } return null; } // ── Download manager ──────────────────────────────────────────────────────── interface DownloadState { request: http.ClientRequest | null; writeStream: fs.WriteStream | null; partialPath: string | null; } const state: DownloadState = { request: null, writeStream: null, partialPath: null }; function cleanupPartial(): void { if (state.writeStream) { try { state.writeStream.destroy(); } catch { /* ignore */ } state.writeStream = null; } if (state.partialPath && fs.existsSync(state.partialPath)) { try { fs.unlinkSync(state.partialPath); } catch { /* ignore */ } } state.partialPath = null; state.request = null; } function fetchFollowingRedirects(url: string): Promise { return new Promise((resolve, reject) => { const go = (u: string, depth: number) => { if (depth > 5) return reject(new Error('Too many redirects')); const req = https.get(u, (res) => { const status = res.statusCode || 0; const location = res.headers.location; if (status >= 300 && status < 400 && location) { res.resume(); const next = new URL(location, u).toString(); go(next, depth + 1); return; } if (status !== 200) { res.resume(); reject(new Error(`HTTP ${status} fetching ${u}`)); return; } resolve(res); }); req.on('error', reject); state.request = req; }; go(url, 0); }); } async function downloadHighQuality( window: BrowserWindow, ): Promise<{ success: boolean; message: string; path?: string }> { if (state.request || state.writeStream) { return { success: false, message: 'Download already in progress' }; } const finalPath = highQualityPath(); const partialPath = finalPath + '.partial'; state.partialPath = partialPath; try { // Remove any stale partial before starting. if (fs.existsSync(partialPath)) fs.unlinkSync(partialPath); const res = await fetchFollowingRedirects(SOUNDFONT_URL); const totalBytes = parseInt(res.headers['content-length'] || '0', 10); const writeStream = fs.createWriteStream(partialPath); state.writeStream = writeStream; const hash = crypto.createHash('sha256'); let bytesDownloaded = 0; let lastProgressAt = 0; res.on('data', (chunk: Buffer) => { hash.update(chunk); bytesDownloaded += chunk.length; // Throttle progress events to ~5/sec so we don't flood IPC. const now = Date.now(); if (now - lastProgressAt > 200) { lastProgressAt = now; const percent = totalBytes > 0 ? (bytesDownloaded / totalBytes) * 100 : 0; if (!window.isDestroyed()) { window.webContents.send('soundfont:downloadProgress', { bytesDownloaded, totalBytes, percent, }); } } }); await new Promise((resolve, reject) => { res.pipe(writeStream); writeStream.on('finish', () => resolve()); writeStream.on('error', reject); res.on('error', reject); }); state.writeStream = null; state.request = null; const digest = hash.digest('hex'); if (digest !== SOUNDFONT_SHA256) { fs.unlinkSync(partialPath); return { success: false, message: `Checksum mismatch — expected ${SOUNDFONT_SHA256}, got ${digest}. File has been discarded.`, }; } fs.renameSync(partialPath, finalPath); state.partialPath = null; if (!window.isDestroyed()) { window.webContents.send('soundfont:downloadProgress', { bytesDownloaded, totalBytes, percent: 100, }); } return { success: true, message: 'Download complete', path: finalPath }; } catch (err: any) { cleanupPartial(); return { success: false, message: `Download failed: ${err?.message || err}` }; } } function cancelDownload(): { success: boolean; message: string } { if (!state.request && !state.writeStream) { return { success: false, message: 'No download in progress' }; } try { state.request?.destroy(new Error('Download cancelled by user')); } catch { /* ignore */ } cleanupPartial(); return { success: true, message: 'Download cancelled' }; } // ── IPC wiring ────────────────────────────────────────────────────────────── export function initSoundfontManager(getMainWindow: () => BrowserWindow | null): void { ipcMain.handle('soundfont:getStatus', () => { const cfg = getDesktopConfig(); const highPath = highQualityPath(); const highDownloaded = fs.existsSync(highPath); return { activeQuality: cfg.soundfontQuality === 'high' ? 'high' : 'default', highDownloaded, highPath: highDownloaded ? highPath : null, downloadInProgress: !!state.request || !!state.writeStream, expectedSizeMB: SOUNDFONT_EXPECTED_SIZE_MB, }; }); ipcMain.handle('soundfont:downloadHighQuality', async () => { const win = getMainWindow(); if (!win) return { success: false, message: 'No main window' }; return await downloadHighQuality(win); }); ipcMain.handle('soundfont:cancelDownload', () => { return cancelDownload(); }); ipcMain.handle('soundfont:setQuality', (_event, quality: SoundfontQuality) => { if (quality !== 'default' && quality !== 'high') { return { success: false, message: `Unknown quality: ${quality}` }; } if (quality === 'high' && !fs.existsSync(highQualityPath())) { return { success: false, message: 'High-quality soundfont not downloaded yet' }; } setDesktopConfig({ soundfontQuality: quality }); restartPython(); return { success: true, message: 'Restarting audio engine…' }; }); // Clean up any partial on quit so disk state stays tidy. app.on('before-quit', () => { cleanupPartial(); }); }