Metabolism: rule program, song-driven life, tribe VMs; M2.7 design (strains & allegiances, living wells)

This commit is contained in:
Dejvino
2026-10-28 10:07:05 +01:00
parent 16f4b4a805
commit f939351689
28 changed files with 3199 additions and 601 deletions
+145
View File
@@ -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()],
});