// Vite config for the demo page. // // The demo needs the WAV bank, which lives outside this package at // ../flow-state/test/songs (the same bank the strip/report tools read). This // plugin gives the browser a clean, versionless URI surface for it: // // GET /songs/catalog.json -> { fps, defaultSeed, songs: [...] } (node-side analysis) // GET /songs/.wav -> the raw WAV, decoded in-browser with the // exact same decodeWav/analyse code the tools use // // In dev the files are streamed from disk; on `vite build` they are emitted // into dist/songs so `vite preview` shows the same page. import { defineConfig } from 'vite'; import { createReadStream, existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; import { resolve, basename } from 'node:path'; import { fileURLToPath } from 'node:url'; import { loadSong } from './tools/load.js'; const BANK = fileURLToPath(new URL('../flow-state/test/songs', import.meta.url)); const PREPARED = fileURLToPath(new URL('./out/prepared', import.meta.url)); // Catalog: one full analysis per song (17 tracks, ~0.2 s each). Built once per // server process; loads in the browser are cached client-side, so this is the // only full sweep the demo ever pays for. let catalogPromise = null; function getCatalog() { if (!catalogPromise) catalogPromise = buildCatalog(); return catalogPromise; } async function buildCatalog() { const files = readdirSync(BANK).filter((f) => f.endsWith('.wav')).sort(); const songs = []; for (const f of files) { // Yield between songs so the server answers requests while the first // catalog builds; the page merely waits a beat. await new Promise((r) => setImmediate(r)); try { const { song, analysis } = loadSong(resolve(BANK, f), { fps: 30, seed: 12345 }); songs.push({ file: f, name: basename(f, '.wav'), duration: analysis.duration, frames: analysis.frames, species: song.name, seed: song.seed, hue: song.palette.hue, sat: song.palette.sat, summary: { bpm: analysis.summary.bpm, beatStrength: analysis.summary.beatStrength, centroid: analysis.summary.centroid, flatness: analysis.summary.flatness, dynamics: analysis.summary.dynamics, onsetDensity: analysis.summary.onsetDensity, }, laws: song.laws.map((l) => ({ start: l.start, kind: l.kind })), }); } catch (err) { console.warn(`[song-bank] skipping ${f}: ${err.message}`); } } return { fps: 30, defaultSeed: 12345, songs }; } /** Only a plain filename inside the bank may be served; never a traversal. */ function playableWav(file) { const resolved = resolve(BANK, file); if (basename(resolved) !== file || !resolved.startsWith(BANK)) return null; try { return statSync(resolved).isFile() ? resolved : null; } catch { return null; } } function songBankPlugin() { return { name: 'metabolism-song-bank', configureServer(server) { // Warm the catalog in the background so the first page load is fast. getCatalog(); server.middlewares.use('/songs', (req, res, next) => { const pathname = decodeURIComponent((req.url || '').split('?')[0]); if (pathname === '/catalog.json' || pathname === '') { getCatalog().then( (catalog) => { res.statusCode = 200; res.setHeader('content-type', 'application/json; charset=utf-8'); res.setHeader('cache-control', 'no-cache'); res.end(JSON.stringify(catalog)); }, (err) => { res.statusCode = 500; res.end(`catalog failed: ${err.message}`); } ); return; } const file = playableWav(pathname.replace(/^\//, '')); if (!file) return next(); res.statusCode = 200; res.setHeader('content-type', 'audio/wav'); res.setHeader('cache-control', 'no-cache'); createReadStream(file).pipe(res); }); // Prepared analyses (tools/prepare.js) make viewing skip analysis. server.middlewares.use('/prepared', (req, res, next) => { const name = decodeURIComponent((req.url || '').split('?')[0]).replace(/^\//, ''); const file = resolve(PREPARED, name); if (basename(file) !== name || !file.startsWith(PREPARED)) return next(); try { if (!statSync(file).isFile()) return next(); } catch { return next(); } res.statusCode = 200; res.setHeader('content-type', 'application/json; charset=utf-8'); res.setHeader('cache-control', 'no-cache'); createReadStream(file).pipe(res); }); }, async generateBundle() { const catalog = await getCatalog(); for (const s of catalog.songs) { const file = playableWav(s.file); if (file) this.emitFile({ type: 'asset', fileName: `songs/${s.file}`, source: readFileSync(file) }); } this.emitFile({ type: 'asset', fileName: 'songs/catalog.json', source: JSON.stringify(catalog) }); if (existsSync(PREPARED)) { for (const f of readdirSync(PREPARED).filter((f) => f.endsWith('.json'))) { this.emitFile({ type: 'asset', fileName: `prepared/${f}`, source: readFileSync(resolve(PREPARED, f)) }); } } }, }; } export default defineConfig({ plugins: [songBankPlugin()], // Cross-origin isolation: browsers expose SharedArrayBuffer only when // both headers are present. The region pool needs it once the sim runs // inside a browser worker; the demo is fully same-origin, so requiring // corp costs nothing here. server: { headers: { 'Cross-Origin-Opener-Policy': 'same-origin', 'Cross-Origin-Embedder-Policy': 'require-corp', }, }, preview: { headers: { 'Cross-Origin-Opener-Policy': 'same-origin', 'Cross-Origin-Embedder-Policy': 'require-corp', }, }, });