161 lines
6.7 KiB
JavaScript
161 lines
6.7 KiB
JavaScript
// 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/<name>.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',
|
|
},
|
|
},
|
|
}); |