Initial release

This commit is contained in:
civ
2026-08-16 18:24:52 +07:00
commit 876886a39a
13244 changed files with 2353959 additions and 0 deletions
+624
View File
@@ -0,0 +1,624 @@
// 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<string, unknown>): 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<string, unknown> = { 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<string, unknown> = {};
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<boolean> {
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;