// vst.ts — VST3 Post-Processing routes // // Endpoints: // GET /api/vst/scan — Scan for installed VST3 plugins // GET /api/vst/chain — Get current chain config // PUT /api/vst/chain — Update chain config // POST /api/vst/gui — Launch plugin GUI (native window) // POST /api/vst/process — Process audio through the full chain import { Router } from 'express'; import fs from 'fs'; import path from 'path'; import { execFile, spawn, ChildProcess } from 'child_process'; import { promisify } from 'util'; import { config } from '../config.js'; const execFileAsync = promisify(execFile); const router = Router(); // ── Types ─────────────────────────────────────────────────── export interface VstPlugin { name: string; vendor: string; version: string; path: string; uid: string; subcategories: string; } export interface ChainEntry { uid: string; name: string; vendor: string; path: string; // .vst3 module path enabled: boolean; statePath: string; // .vststate file path (may not exist yet) } interface ChainConfig { plugins: ChainEntry[]; } // ── Helpers ───────────────────────────────────────────────── function ensureDirs(): void { fs.mkdirSync(config.vst.statesDir, { recursive: true }); fs.mkdirSync(path.dirname(config.vst.chainFile), { recursive: true }); } function loadChain(): ChainConfig { ensureDirs(); try { if (fs.existsSync(config.vst.chainFile)) { const raw = fs.readFileSync(config.vst.chainFile, 'utf-8'); return JSON.parse(raw) as ChainConfig; } } catch (err) { console.error('[VST] Failed to load chain config:', err); } return { plugins: [] }; } function saveChain(chain: ChainConfig): void { ensureDirs(); fs.writeFileSync(config.vst.chainFile, JSON.stringify(chain, null, 2), 'utf-8'); } function statePathForPlugin(uid: string): string { return path.join(config.vst.statesDir, `${uid}.vststate`); } // Cached scan results (scanning 40 plugins takes ~2-3 seconds) let cachedPlugins: VstPlugin[] | null = null; // ── GET /scan — Scan for installed VST3 plugins ───────────── router.get('/scan', async (_req, res) => { try { const exe = config.vst.exe; if (!fs.existsSync(exe)) { res.status(503).json({ error: `vst-host.exe not found at ${exe}`, hint: 'Rebuild the engine with: engine/build.cmd', }); return; } console.log('[VST] Scanning for plugins...'); const { stdout, stderr } = await execFileAsync(exe, ['--scan'], { timeout: 30_000, maxBuffer: 1024 * 1024, // 1MB should be plenty for JSON }); if (stderr) { for (const line of stderr.split('\n')) { if (line.trim()) console.log(`[VST] ${line.trim()}`); } } if (!stdout || stdout.trim().length === 0) { cachedPlugins = []; res.json({ plugins: [] }); return; } const plugins: VstPlugin[] = JSON.parse(stdout); cachedPlugins = plugins; console.log(`[VST] Found ${plugins.length} plugin(s)`); res.json({ plugins }); } catch (err: any) { console.error('[VST] Scan failed:', err.message); res.status(500).json({ error: err.message }); } }); // ── GET /chain — Get current chain config ─────────────────── router.get('/chain', (_req, res) => { const chain = loadChain(); res.json(chain); }); // ── PUT /chain — Update chain config ──────────────────────── router.put('/chain', (req, res) => { const { plugins } = req.body as ChainConfig; if (!Array.isArray(plugins)) { res.status(400).json({ error: 'plugins array required' }); return; } // Validate and ensure state paths const validated: ChainEntry[] = plugins.map(p => ({ uid: p.uid, name: p.name, vendor: p.vendor || '', path: p.path, enabled: p.enabled !== false, statePath: p.statePath || statePathForPlugin(p.uid), })); const chain: ChainConfig = { plugins: validated }; saveChain(chain); console.log(`[VST] Chain updated: ${validated.length} plugin(s), ${validated.filter(p => p.enabled).length} enabled`); res.json(chain); }); // ── POST /gui — Launch plugin GUI ─────────────────────────── router.post('/gui', (req, res) => { const { pluginPath, uid } = req.body; if (!pluginPath) { res.status(400).json({ error: 'pluginPath required' }); return; } const exe = config.vst.exe; if (!fs.existsSync(exe)) { res.status(503).json({ error: 'vst-host.exe not found' }); return; } const statePath = uid ? statePathForPlugin(uid) : ''; const args = ['--gui', '--plugin', pluginPath]; if (statePath) { args.push('--state', statePath); } console.log(`[VST] Launching GUI: ${path.basename(pluginPath)}`); // Spawn detached — the GUI process lives independently const child = spawn(exe, args, { detached: true, stdio: 'ignore', }); child.unref(); res.json({ ok: true, pid: child.pid }); }); // ── POST /process — Process audio through the VST chain ───── router.post('/process', async (req, res) => { const { inputPath, outputPath } = req.body; if (!inputPath || !outputPath) { res.status(400).json({ error: 'inputPath and outputPath required' }); return; } const exe = config.vst.exe; if (!fs.existsSync(exe)) { res.status(503).json({ error: 'vst-host.exe not found' }); return; } if (!fs.existsSync(inputPath)) { res.status(404).json({ error: `Input file not found: ${inputPath}` }); return; } try { const chain = loadChain(); const enabled = chain.plugins.filter(p => p.enabled); if (enabled.length === 0) { // No plugins enabled — just copy fs.copyFileSync(inputPath, outputPath); res.json({ ok: true, skipped: true }); return; } // Write temporary chain JSON for vst-host.exe const tempChainFile = path.join(config.vst.statesDir, `_temp_chain_${Date.now()}.json`); const chainData = { plugins: enabled.map(p => ({ path: p.path, state: fs.existsSync(p.statePath) ? p.statePath : '', enabled: true, })), }; fs.writeFileSync(tempChainFile, JSON.stringify(chainData), 'utf-8'); console.log(`[VST] Processing through ${enabled.length} plugin(s):`); for (const p of enabled) { console.log(`[VST] → ${p.name} (${p.vendor})`); } const startTime = Date.now(); const { stderr } = await execFileAsync(exe, [ '--process-chain', '--chain', tempChainFile, '--input', inputPath, '--output', outputPath, ], { timeout: 300_000 }); // 5 min timeout const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); if (stderr) { for (const line of stderr.split('\n')) { if (line.trim()) console.log(`[VST] ${line.trim()}`); } } // Clean up temp chain file try { fs.unlinkSync(tempChainFile); } catch {} console.log(`[VST] Processing complete in ${elapsed}s → ${path.basename(outputPath)}`); res.json({ ok: true, elapsed: parseFloat(elapsed) }); } catch (err: any) { console.error('[VST] Process failed:', err.message); res.status(500).json({ error: err.message }); } }); // ── Monitor — Real-time playback through VST chain ────────── let monitorProcess: ChildProcess | null = null; let monitorPaused = false; let monitorCurrentTrack = ''; // track path currently loaded in monitor const monitorControlFile = () => path.join(config.vst.statesDir, 'monitor_control.json'); const monitorStatusFile = () => path.join(config.vst.statesDir, 'monitor_status.json'); function writeMonitorControl(data: Record): void { fs.writeFileSync(monitorControlFile(), JSON.stringify(data), 'utf-8'); } function isMonitorAlive(): boolean { if (!monitorProcess) return false; try { // Sending signal 0 tests if the process is alive (throws if dead) process.kill(monitorProcess.pid!, 0); return true; } catch { monitorProcess = null; return false; } } /** Resolve a trackPath (from the UI) to an absolute filesystem path. * Handles: "/audio/uuid.wav", "http://localhost:3001/audio/uuid.wav", * "D:\\...\\uuid.wav", and bare "uuid.wav". */ function resolveTrackPath(trackPath: string): string { // Strip URL origin if present let p = trackPath; try { const url = new URL(p); p = url.pathname; // "/audio/uuid.wav" } catch { /* not a full URL */ } // If it's a Windows absolute path with drive letter, keep it if (/^[A-Za-z]:[\\/]/.test(p)) return p; // Otherwise extract just the filename and resolve from audioDir const filename = path.basename(p); return path.join(config.data.audioDir, filename); } // POST /monitor/start — Start real-time monitoring router.post('/monitor/start', (req, res) => { const { trackPath } = req.body; if (!trackPath) { res.status(400).json({ error: 'trackPath required' }); return; } const absTrackPath = resolveTrackPath(trackPath); if (!fs.existsSync(absTrackPath)) { res.status(404).json({ error: `Track not found: ${absTrackPath}` }); return; } const exe = config.vst.exe; if (!fs.existsSync(exe)) { res.status(503).json({ error: 'vst-host.exe not found' }); return; } // Kill existing monitor if running if (isMonitorAlive()) { writeMonitorControl({ action: 'stop' }); setTimeout(() => { try { monitorProcess?.kill(); } catch {} monitorProcess = null; }, 1000); } // Write temp chain JSON for the monitor (uses statePath for state files) const chain = loadChain(); const enabled = chain.plugins.filter(p => p.enabled); if (enabled.length === 0) { res.status(400).json({ error: 'No enabled plugins in chain' }); return; } const tempChainFile = path.join(config.vst.statesDir, '_monitor_chain.json'); const chainData = { plugins: enabled.map(p => ({ path: p.path, state: fs.existsSync(p.statePath) ? p.statePath : '', enabled: true, })), }; fs.writeFileSync(tempChainFile, JSON.stringify(chainData), 'utf-8'); // Write initial control file writeMonitorControl({ track: absTrackPath, action: 'play' }); monitorPaused = false; monitorCurrentTrack = absTrackPath; console.log(`[VST] Starting monitor: ${enabled.length} plugin(s), track=${path.basename(absTrackPath)}`); const child = spawn(exe, [ '--monitor', '--chain', tempChainFile, '--input', absTrackPath, '--control', monitorControlFile(), '--status', monitorStatusFile(), ], { stdio: ['ignore', 'ignore', 'pipe'], }); monitorProcess = child; // Log stderr child.stderr?.on('data', (data: Buffer) => { for (const line of data.toString().split('\n')) { if (line.trim()) console.log(`[VST] ${line.trim()}`); } }); child.on('exit', (code) => { console.log(`[VST] Monitor exited (code ${code})`); monitorProcess = null; // Clean up temp chain try { fs.unlinkSync(tempChainFile); } catch {} }); res.json({ ok: true, pid: child.pid, plugins: enabled.length }); }); // POST /monitor/stop — Stop monitoring router.post('/monitor/stop', (_req, res) => { if (!isMonitorAlive()) { res.json({ ok: true, wasRunning: false }); return; } writeMonitorControl({ action: 'stop' }); monitorPaused = false; monitorCurrentTrack = ''; // Give it a moment to save state gracefully, then force-kill setTimeout(() => { if (isMonitorAlive()) { try { monitorProcess?.kill(); } catch {} monitorProcess = null; } }, 3000); res.json({ ok: true, wasRunning: true }); }); // POST /monitor/switch — Switch to a different track router.post('/monitor/switch', (req, res) => { const { trackPath } = req.body; if (!trackPath) { res.status(400).json({ error: 'trackPath required' }); return; } if (!isMonitorAlive()) { res.status(400).json({ error: 'Monitor is not running' }); return; } const absTrackPath = resolveTrackPath(trackPath); if (!fs.existsSync(absTrackPath)) { res.status(404).json({ error: `Track not found: ${absTrackPath}` }); return; } console.log(`[VST] Monitor switching track → ${path.basename(absTrackPath)}`); writeMonitorControl({ track: absTrackPath, action: 'play' }); res.json({ ok: true }); }); // GET /monitor/status — Is the monitor running? Returns position too. router.get('/monitor/status', (_req, res) => { const running = isMonitorAlive(); let position = 0; let duration = 0; if (running) { try { const statusPath = monitorStatusFile(); if (fs.existsSync(statusPath)) { const raw = fs.readFileSync(statusPath, 'utf-8'); const data = JSON.parse(raw); position = data.position || 0; duration = data.duration || 0; } } catch { /* ignore parse errors */ } } res.json({ running, paused: monitorPaused, pid: monitorProcess?.pid || null, position, duration }); }); // POST /monitor/seek — Seek to a position in seconds router.post('/monitor/seek', (req, res) => { const { position } = req.body; if (typeof position !== 'number') { res.status(400).json({ error: 'position (number) required' }); return; } if (!isMonitorAlive()) { res.status(400).json({ error: 'Monitor is not running' }); return; } // Read current control file and add seek field let controlData: Record = { action: 'play' }; try { const raw = fs.readFileSync(monitorControlFile(), 'utf-8'); controlData = JSON.parse(raw); } catch { /* start fresh */ } controlData.seek = position; writeMonitorControl(controlData); res.json({ ok: true, position }); }); // POST /monitor/pause — pause playback router.post('/monitor/pause', (_req, res) => { if (!isMonitorAlive()) { res.status(400).json({ error: 'Monitor is not running' }); return; } monitorPaused = true; writeMonitorControl({ action: 'pause' }); res.json({ ok: true }); }); // POST /monitor/resume — resume playback router.post('/monitor/resume', (_req, res) => { if (!isMonitorAlive()) { res.status(400).json({ error: 'Monitor is not running' }); return; } monitorPaused = false; let ctrl: Record = {}; try { const raw = fs.readFileSync(monitorControlFile(), 'utf-8'); ctrl = JSON.parse(raw); } catch { /* start fresh */ } writeMonitorControl({ ...ctrl, action: 'play' }); res.json({ ok: true }); }); // POST /monitor/restart — kill and restart monitor, reloading state files from disk // This is how GUI parameter changes take effect in the monitor. router.post('/monitor/restart', async (_req, res) => { const exe = config.vst.exe; const track = monitorCurrentTrack; if (!track || !fs.existsSync(track)) { res.status(400).json({ error: 'No track loaded in monitor' }); return; } if (!fs.existsSync(exe)) { res.status(503).json({ error: 'vst-host.exe not found' }); return; } // Gracefully stop the existing process if (isMonitorAlive()) { writeMonitorControl({ action: 'stop' }); try { monitorProcess?.kill(); } catch {} monitorProcess = null; } monitorPaused = false; // Brief pause for process cleanup and state file flush await new Promise(r => setTimeout(r, 800)); // Reload chain — picks up any GUI state file changes const chain = loadChain(); const enabled = chain.plugins.filter(p => p.enabled); if (enabled.length === 0) { res.status(400).json({ error: 'No enabled plugins in chain' }); return; } const tempChainFile = path.join(config.vst.statesDir, '_monitor_chain.json'); const chainData = { plugins: enabled.map(p => ({ path: p.path, state: fs.existsSync(p.statePath) ? p.statePath : '', enabled: true, })), }; fs.writeFileSync(tempChainFile, JSON.stringify(chainData), 'utf-8'); writeMonitorControl({ track, action: 'play' }); monitorCurrentTrack = track; console.log(`[VST] Restarting monitor: ${enabled.length} plugin(s), track=${path.basename(track)}`); const child = spawn(exe, [ '--monitor', '--chain', tempChainFile, '--input', track, '--control', monitorControlFile(), '--status', monitorStatusFile(), ], { stdio: ['ignore', 'ignore', 'pipe'] }); monitorProcess = child; child.stderr?.on('data', (data: Buffer) => { for (const line of data.toString().split('\n')) { if (line.trim()) console.log(`[VST] ${line.trim()}`); } }); child.on('exit', (code) => { console.log(`[VST] Monitor exited (code ${code})`); monitorProcess = null; try { fs.unlinkSync(tempChainFile); } catch {} }); res.json({ ok: true, pid: child.pid, plugins: enabled.length }); }); /** * Apply the VST chain to a WAV file in-place. * Returns true if processing was applied, false if skipped. */ export async function applyVstChain(wavPath: string): Promise { const chain = loadChain(); const enabled = chain.plugins.filter(p => p.enabled); if (enabled.length === 0) return false; const exe = config.vst.exe; if (!fs.existsSync(exe)) { console.warn('[VST] vst-host.exe not found, skipping chain'); return false; } // Process in-place: output to temp, then replace const tempOut = wavPath + '.vst_processed.wav'; const tempChain = path.join(config.vst.statesDir, `_chain_${Date.now()}.json`); try { ensureDirs(); const chainData = { plugins: enabled.map(p => ({ path: p.path, state: fs.existsSync(p.statePath) ? p.statePath : '', enabled: true, })), }; fs.writeFileSync(tempChain, JSON.stringify(chainData), 'utf-8'); console.log(`[VST] Applying chain (${enabled.length} plugins) to ${path.basename(wavPath)}`); await execFileAsync(exe, [ '--process-chain', '--chain', tempChain, '--input', wavPath, '--output', tempOut, ], { timeout: 300_000 }); // Replace original with processed fs.copyFileSync(tempOut, wavPath); return true; } catch (err: any) { console.error('[VST] Chain processing failed:', err.message); return false; } finally { try { fs.unlinkSync(tempOut); } catch {} try { fs.unlinkSync(tempChain); } catch {} } } export default router;