Files
feedBack-desktop/src/main/soundfont-manager.ts
T

258 lines
9.8 KiB
TypeScript

// 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 <userData>/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<DesktopConfig>): 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<http.IncomingMessage> {
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<void>((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();
});
}