web: README, headless CDP runner, dev server fix

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Aqua 2026-11-05 19:00:11 +00:00
commit f7d5c684b7
4 changed files with 97 additions and 2 deletions

48
web/README.md Normal file
View file

@ -0,0 +1,48 @@
# Perlines v2 (browser)
Zero-install audio visualizer: one static folder, WebAudio for analysis, WebGL2 for drawing, no build step, no dependencies, no network calls.
**Status, plainly: no human has watched this run.** It was written and tested by an agent. The DSP is checked by synthetic-signal tests (below). The renderers were exercised only by headless-Chrome captures and a frame-time script; nobody has listened to music through it, looked at it on a real display, or tried the microphone path. Expect rough looks and tuning mistakes.
## Run
```sh
node web/tools/serve.mjs 8080 # then open http://localhost:8080/
```
Any static server works (`python3 -m http.server -d web`). `localhost` counts as a secure context, so the microphone prompt works; opened from `file://` the mic may be refused. Then: drop an audio file on the page, press Microphone, or press Demo (a silent synthetic 120 bpm spectrum, for looking at scenes without sound). Keys: `1` `2` `3` scenes, `F` fullscreen, `H` HUD.
## What is in it
| Piece | File | What it does |
|---|---|---|
| Onset detector | `web/src/dsp.js` `OnsetDetector` | Log-compressed spectral flux against a max-filtered previous frame, moving-median adaptive threshold, local-peak pick, refractory period. Causal: confirms an onset one frame late. |
| Key estimate | `web/src/dsp.js` `KeyEstimator` | Peak-based chroma (parabolic-interpolated spectral peaks folded to 12 pitch classes, 4 s half-life) correlated against the 24 Krumhansl-Kessler key profiles. The key picks the scene tint: tonic walks the circle of fifths for hue, mode sets warmth. |
| Analysis state | `web/src/analysis.js` | One object all scenes read: 128 log bands, bass/mid/treble, onset counter, chroma, key, tint. Also `DemoFeed`. |
| Perlin noise | `web/src/noise.js` | Classic 3D Perlin and a 2D curl field (divergence-free flow) for the CPU-side paths. |
| Scenes | `web/src/scenes/` | Aurora disk, burning water, vector trails (below). |
| Persistence | `web/src/gl.js` `Feedback` | Two render targets; each frame fades the previous one and the scene adds on top. |
### Scenes
- **Aurora disk** (`aurora.js`): spectrum-to-mesh extrusion. A 112 x 224 polar mesh; angle is frequency (mirrored), radius is age, vertex height is the band level from a 128-row spectrum-history texture, modulated by noise. Onsets pulse and spin it.
- **Burning water** (`water.js`): a ripple plane lit in flame colours, plus an onset-driven spray. Particles are stateless: onsets write `(birth, seed, strength, lane)` into a ring buffer, and the vertex shader computes every position from age.
- **Vector trails** (`trails.js`): 96 heads, one per spectrum lane, ride a Perlin curl field; each leaves a 72-point polyline. An onset kicks the speed and respawns a spray of heads from the centre.
## Tests and measurements
```sh
cd web && npm test # node --test test/dsp.test.mjs (node >= 20, nothing to install)
```
Covers: ten synthetic clicks found at two hop sizes (11.6 ms and 16.7 ms) with no false positives; silence, steady noise and a sustained tone give no onsets; a 20 ms doublet merges under the refractory period; the key estimate reads C major, A minor and (transposed) B minor from synthesized chord progressions. Fixtures are deliberately plain: the estimate is known to confuse relative major/minor when a clip leans on the dominant, and one test fixture was rewritten to put its tonic in more chords (the estimator was not tuned to the fixture).
Frame time: the HUD shows rolling p50/p95 of requestAnimationFrame intervals against the 16.7 ms budget and CPU submit time. `?bench` runs the three scenes on the synthetic feed and prints JSON (`?w=1920&h=1080` fixes the drawing-buffer size, `&sync` times each frame with `gl.finish()` so vsync does not hide cost, `&ms=` sets seconds per scene). `web/tools/headless.mjs` drives headless Chrome over CDP to collect it. Results of the last run are in `web/BENCH.md`.
## Provenance
The ideas come from the 2013 Unity "Aethervis" project (Perlin-noise paths with spray and trails; spectrum-driven mesh extrusion; the Aurora, BurningWater and vectortrails scenes) and from the author's analysis-vocabulary notes (onsets, chroma, key). No source, shader or asset was copied; this is fresh code under the repository's licence. The analysis is written from published techniques (spectral flux, Krumhansl-Kessler profiles) and does not port Vamp/aubio/QM-DSP code, which is GPL-family.
## Not done
Beat/tempo tracking, per-note lanes (the C# client has them), audio-worklet capture (onsets are quantized to the render frame rate), mobile tuning, a quality auto-scaler.

View file

@ -124,7 +124,7 @@ function frame(now) {
`${sc.name} ${canvas.width}x${canvas.height}\n` +
`frame ${(rn ? pct(ring, rn, 0.5) : 0).toFixed(1)} ms p50 ${pct(ring, rn, 0.95).toFixed(1)} p95 (budget 16.7) cpu ${cpuMs.toFixed(2)} ms` +
(timer && gpuMs ? ` gpu ${gpuMs.toFixed(2)} ms` : '') + '\n' +
`key ${k.name}${k.mode === 'minor' ? '' : ''} (${(k.confidence * 100) | 0}%) onsets ${analysis.onset.count} flux ${analysis.flux.toFixed(2)} ` +
`key ${k.name} (${(k.confidence * 100) | 0}%) onsets ${analysis.onset.count} flux ${analysis.flux.toFixed(2)} ` +
`source ${demo ? 'synthetic demo' : audio.mode}`;
}
requestAnimationFrame(frame);

46
web/tools/headless.mjs Normal file
View file

@ -0,0 +1,46 @@
// Drive headless Chrome over CDP (no dependencies; needs node >= 22 for global WebSocket).
// node tools/headless.mjs <chrome.exe> <url> bench -> prints PERLINES_BENCH json, console errors
// node tools/headless.mjs <chrome.exe> <url> shot <out.png> [waitMs]
// The page must already be served (tools/serve.mjs). Exit code 1 on page errors.
import { spawn } from 'node:child_process';
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const [chrome, url, mode, out, waitArg] = process.argv.slice(2);
const port = 9300 + Math.floor(Math.random() * 500);
const dir = mkdtempSync(join(tmpdir(), 'perlines-chrome-'));
const proc = spawn(chrome, [`--remote-debugging-port=${port}`, `--user-data-dir=${dir}`, '--headless=new', '--no-first-run',
'--autoplay-policy=no-user-gesture-required', '--enable-unsafe-swiftshader', '--ignore-gpu-blocklist', '--window-size=1280,720',
...(process.env.CHROME_FLAGS ? process.env.CHROME_FLAGS.split(' ') : []), 'about:blank'], { stdio: 'ignore' });
const stop = code => { try { proc.kill(); } catch {} process.exit(code); };
const sleep = ms => new Promise(r => setTimeout(r, ms));
let targets;
for (let i = 0; i < 60; i++) { try { targets = await (await fetch(`http://127.0.0.1:${port}/json`)).json(); if (targets.some(t => t.type === 'page')) break; } catch {} await sleep(250); }
const ws = new WebSocket(targets.find(t => t.type === 'page').webSocketDebuggerUrl);
await new Promise(r => ws.addEventListener('open', r));
let id = 0; const pending = new Map(); let errors = 0, benchLine = null;
ws.addEventListener('message', e => {
const m = JSON.parse(e.data);
if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); return; }
if (m.method === 'Runtime.consoleAPICalled') {
const text = m.params.args.map(a => a.value ?? a.description).join(' ');
if (text.startsWith('PERLINES_BENCH ')) benchLine = text.slice(15); else console.error(`[console.${m.params.type}] ${text}`);
if (m.params.type === 'error') errors++;
} else if (m.method === 'Runtime.exceptionThrown') { errors++; console.error('[exception]', JSON.stringify(m.params.exceptionDetails.exception?.description || m.params.exceptionDetails.text)); }
});
const send = (method, params = {}) => new Promise(r => { const i = ++id; pending.set(i, r); ws.send(JSON.stringify({ id: i, method, params })); });
await send('Runtime.enable'); await send('Page.enable');
await send('Page.navigate', { url });
if (mode === 'bench') {
for (let i = 0; i < 400 && !benchLine; i++) await sleep(250);
if (!benchLine) { console.error('no bench result'); stop(1); }
console.log(benchLine); stop(errors ? 1 : 0);
} else {
await sleep(+waitArg || 4000);
const r = await send('Page.captureScreenshot', { format: 'png' });
writeFileSync(out, Buffer.from(r.result.data, 'base64'));
stop(errors ? 1 : 0);
}

View file

@ -12,6 +12,7 @@ http.createServer(async (req, res) => {
try {
const f = join(root, p.endsWith('/') ? p + 'index.html' : p);
if (!f.startsWith(root)) throw new Error('outside root');
res.writeHead(200, { 'content-type': types[extname(f)] || 'application/octet-stream' }).end(await readFile(f));
const body = await readFile(f);
res.writeHead(200, { 'content-type': types[extname(f)] || 'application/octet-stream' }).end(body);
} catch { res.writeHead(404).end('not found'); }
}).listen(port, '127.0.0.1', () => console.log(`Perlines on http://localhost:${port}/`));