diff --git a/README.md b/README.md index f5dd81c..790ec69 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,11 @@ # Perlines -Perlines is the 2012 Aethervis trail idea rebuilt as a Fensalir client: audio becomes a rolling field resource that Fensalir's renderer draws as a storm of curl-noise trails, with no private line renderer in the client. +Perlines turns music into a storm of Perlin-noise trails. Two implementations live here: + +- **`web/` (v2, browser):** zero-install WebAudio + WebGL2 visualizer. Static folder, no build, no dependencies. File and microphone input, onset detection, key estimate that tints the scene, three scenes (aurora disk, burning water, vector trails). Entry: `node web/tools/serve.mjs`, then `http://localhost:8080/`; analysis in `web/src/dsp.js`; tests `cd web && npm test`; details in `web/README.md`. **Status: no human has watched it run.** It has synthetic-signal tests and headless-Chrome captures only; no real-GPU frame-time measurement exists yet (`web/BENCH.md`). +- **`src/Perlines/` (Fensalir client, C#):** the 2012 Aethervis trail idea as a client of the Fensalir D3D12 renderer. Described below. + +The Fensalir client: Perlines is the 2012 Aethervis trail idea rebuilt as a Fensalir client: audio becomes a rolling field resource that Fensalir's renderer draws as a storm of curl-noise trails, with no private line renderer in the client. **Valuable here:** the signal path. `src/Perlines/PerlinesSignalProcessor.cs` turns audio into FFT energy, adaptively whitens it per band, detects the key, and projects bins onto musical scale-note lanes. `src/Perlines/PerlinesRuntime.cs` writes one Float32 column per frame into a rolling field resource and hands it to Fensalir (`Perlin4D.cs` and `SmoothNoise.cs` supply the noise). It is a worked example of a Fensalir client that owns a signal and leaves visibility, reservoir reuse, bloom and reconstruction to the renderer. diff --git a/web/BENCH.md b/web/BENCH.md new file mode 100644 index 0000000..8ebe61b --- /dev/null +++ b/web/BENCH.md @@ -0,0 +1,13 @@ +# Frame-time record + +**No GPU measurement exists yet.** The only run so far used headless Chrome with the SwiftShader *software* rasterizer on a shared Linux box. Those numbers say nothing about a real GPU; they show that the scenes run without GL errors and what the JavaScript side costs. The intended target machine (Starfire, GTX 1070) was unreachable (sshd reset every connection) when this was written. + +Run 2026-11-05, 1280x720 drawing buffer, synthetic feed, `?bench&sync&w=1280&h=720&ms=4000` (a 1-pixel readback each frame, so the number is cost, not vsync-capped). 16.7 ms is the 60 Hz budget. + +| Scene | frame p50 | p95 | max | GPU timer p50 (software) | +|---|---|---|---|---| +| Aurora disk | 21.3 ms | 27.1 | 38.6 | 17.9 | +| Burning water | 46.1 ms | 52.6 | 80.7 | 42.5 | +| Vector trails | 12.2 ms | 17.9 | 21.4 | 9.2 | + +JavaScript submit cost (analysis, uploads, draw calls) was 0.8 ms p50 and under 2.7 ms p95 per frame in the vsync run. Software rasterization is CPU-bound by pixels and vertices, so the water scene (full-screen 3 x fbm per pixel) is the one to watch on a real GPU, particularly at 4K or high-dpi. Run `node web/tools/serve.mjs` and open `/?bench&sync&w=1920&h=1080` in Chrome, or use `web/tools/headless.mjs`, on the target to replace this table. diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..1e04c52 --- /dev/null +++ b/web/README.md @@ -0,0 +1,51 @@ +# 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` | Four-band spectral flux in dB (40-150, 150-600, 600-2500, 2.5-12 kHz) against a per-bin max of the two previous frames (max-filtered across +-1 bin), per-band median + 8 MAD threshold with a per-band minimum rise, local-peak pick, one refractory period over all bands. Level-independent. Causal: confirms an onset one frame late. Synthetic-drum scores: `tools/onset-bench.mjs`. | +| 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 test/onset.test.mjs (node >= 20, nothing to install) +cd web && npm run bench:onsets # full synthetic-drum corpus, before/after table, about 30 s +``` + +Covers (dsp.test): 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). + +Covers (onset.test, synthetic drum corpus from `tools/onset-bench.mjs`: kick/snare/hat patterns at 90/120/150 bpm, three levels, bass+noise beds, 30 fps and 512-sample hops): hit rate, false positives and timing floors for the shipped detector, a margin over the frozen legacy detector, level independence, determinism. Synthetic only; the corpus was adjusted twice while building it (coincident kick+snare counted as one onset; voice tails faded so truncation clicks are not onsets), recorded in the commit message. + +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` reads a pixel back after each frame so the measured frame time is real cost, not capped by vsync, `&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 + +Known weakness: on the synthetic demo feed at a simulated 30 fps the detector finds only about half the kicks (60 fps and 11.6 ms hops are fine); it has never been run on real music. Not built: 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. diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..24d3960 --- /dev/null +++ b/web/index.html @@ -0,0 +1,42 @@ + + + + + +Perlines + + + + +
+ + + + + + + + +
+
Perlines
Drop an audio file anywhere, or use the microphone.
Keys: 1 2 3 scenes, F fullscreen, H HUD.
+Nothing leaves your browser: audio is analysed locally.
+
+ +

+
+
+
diff --git a/web/package.json b/web/package.json
new file mode 100644
index 0000000..c2319ba
--- /dev/null
+++ b/web/package.json
@@ -0,0 +1,13 @@
+{
+  "name": "perlines-web",
+  "version": "2.0.0",
+  "description": "Perlines v2: zero-install browser audio visualizer (WebAudio + WebGL2)",
+  "type": "module",
+  "license": "MIT",
+  "private": true,
+  "scripts": {
+    "test": "node --test test/dsp.test.mjs test/onset.test.mjs",
+    "bench:onsets": "node tools/onset-bench.mjs",
+    "serve": "node tools/serve.mjs"
+  }
+}
diff --git a/web/src/analysis.js b/web/src/analysis.js
new file mode 100644
index 0000000..7ef05b0
--- /dev/null
+++ b/web/src/analysis.js
@@ -0,0 +1,88 @@
+// Turns a frame of dB spectrum into the shared state every scene reads: log-spaced smoothed bands,
+// band levels, onset events, chroma, key and the tint colour derived from the key.
+import { dbToLinear, OnsetDetector, KeyEstimator } from './dsp.js';
+
+export const NB = 128;          // log-spaced display bands
+export const HIST = 128;        // history rows (spectrum texture height)
+
+function hslToRgb(h, s, l) {
+  const f = n => { const k = (n + h * 12) % 12, a = s * Math.min(l, 1 - l); return l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1)); };
+  return [f(0), f(8), f(4)];
+}
+
+export class Analysis {
+  constructor(sampleRate, binCount) {
+    this.sampleRate = sampleRate; this.binCount = binCount;
+    this.lin = new Float32Array(binCount);
+    this.onsets = new OnsetDetector({ binHz: sampleRate / 2 / binCount });
+    this.keyEst = new KeyEstimator(sampleRate, binCount);
+    this.bands = new Float32Array(NB);
+    this.row = new Uint8Array(NB);            // newest row, as texture bytes
+    this.bass = 0; this.mid = 0; this.treble = 0; this.level = 0;
+    this.onset = { count: 0, strength: 0, time: -1 };   // count increments per onset; scenes diff it
+    this.flux = 0;
+    this.key = this.keyEst.key;
+    this.hue = 0.6; this.tint = hslToRgb(0.6, 0.7, 0.55);
+    this.time = 0; this.dt = 1 / 60;
+    // band -> bin edges, log spaced 40 Hz .. 16 kHz
+    const hz = sampleRate / 2 / binCount, lo = 40, hi = Math.min(16000, sampleRate / 2 - hz);
+    this.edges = Array.from({ length: NB + 1 }, (_, i) => Math.min(binCount - 1, Math.round(lo * Math.pow(hi / lo, i / NB) / hz)));
+  }
+
+  /** db: Float32Array in decibels (AnalyserNode.getFloatFrequencyData); t: seconds. */
+  update(db, t) {
+    const dt = Math.min(0.1, Math.max(1e-3, t - this.time || 1 / 60));
+    this.time = t; this.dt = dt;
+    const lin = dbToLinear(db, this.lin);
+
+    const o = this.onsets.push(lin, t);
+    this.flux = this.onsets.flux;
+    if (o) { this.onset.count++; this.onset.strength = o.strength; this.onset.time = t; }
+
+    // bands: peak of dB inside each band, mapped -90..-25 dB to 0..1, fast attack / slow release
+    let b = 0, m = 0, tr = 0, nb = 0, nm = 0, nt = 0;
+    for (let i = 0; i < NB; i++) {
+      const a = this.edges[i], z = Math.max(a + 1, this.edges[i + 1]);
+      let pk = -160;
+      for (let k = a; k < z; k++) if (db[k] > pk) pk = db[k];
+      const v = Math.max(0, Math.min(1, (pk + 90) / 65));
+      const cur = this.bands[i];
+      this.bands[i] = v > cur ? cur + (v - cur) * 0.8 : cur + (v - cur) * (1 - Math.pow(0.02, dt));
+      this.row[i] = Math.round(this.bands[i] * 255);
+      if (i < NB * 0.2) { b += this.bands[i]; nb++; } else if (i < NB * 0.6) { m += this.bands[i]; nm++; } else { tr += this.bands[i]; nt++; }
+    }
+    this.bass = b / nb; this.mid = m / nm; this.treble = tr / nt;
+    this.level = (this.bass + this.mid + this.treble) / 3;
+
+    const key = this.keyEst.push(lin, dt);
+    this.key = key;
+    // tint: hue walks the circle of fifths so related keys sit near each other; confidence gates the move
+    const target = ((key.tonic * 7) % 12) / 12;
+    let d = target - this.hue; d -= Math.round(d);
+    this.hue = (this.hue + d * Math.min(1, dt * (0.15 + key.confidence * 0.8)) + 1) % 1;
+    const major = key.mode === 'major';
+    this.tint = hslToRgb(this.hue, major ? 0.8 : 0.6, major ? 0.58 : 0.5);
+    return this;
+  }
+}
+
+/** Synthetic spectrum for demo mode and benchmarks: a 120 bpm kick/hat pattern over an A-minor pad. */
+export class DemoFeed {
+  constructor(sampleRate, binCount) { this.sr = sampleRate; this.n = binCount; this.db = new Float32Array(binCount); }
+  frame(t) {
+    const { db, n, sr } = this, hz = sr / 2 / n, beat = 0.5;
+    const ph = t % beat, kick = Math.exp(-ph * 9), hat = Math.exp(-((t + beat / 2) % beat) * 40) * ((Math.floor(t / (beat / 2)) % 2) ? 1 : 0.4);
+    const snare = Math.floor(t / beat) % 2 ? Math.exp(-ph * 14) : 0;
+    const chord = [220, 261.63, 329.63, 440, 523.25, 659.25];
+    for (let k = 0; k < n; k++) {
+      const f = k * hz + 1;
+      let v = -105 + 12 * Math.sin(k * 12.9898 + t * 3) * 0.3;
+      v += 78 * kick * Math.exp(-Math.pow(Math.log2(f / 55), 2) * 1.5);
+      v += 62 * snare * Math.exp(-Math.pow(Math.log2(f / 1800), 2) * 0.35);
+      v += 55 * hat * Math.exp(-Math.pow(Math.log2(f / 9000), 2) * 0.5);
+      for (const c of chord) v += 55 * Math.exp(-Math.pow((f - c) / (hz * 0.8 + c * 0.01), 2));
+      db[k] = v;
+    }
+    return db;
+  }
+}
diff --git a/web/src/audio.js b/web/src/audio.js
new file mode 100644
index 0000000..aeaacc2
--- /dev/null
+++ b/web/src/audio.js
@@ -0,0 +1,50 @@
+// Audio inputs. All go through one AnalyserNode; the analysis code reads dB frames from it.
+// File playback uses an