Metabolism: rule program, song-driven life, tribe VMs; M2.7 design (strains & allegiances, living wells)
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
// 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()],
|
||||
});
|
||||
Reference in New Issue
Block a user