P0: Rust engine, wire daemon and audio-reactive descent surface
This commit is contained in:
@@ -0,0 +1,825 @@
|
||||
/* ZHILA · engine
|
||||
audio clock, sample slices, voices, climate, transport, render.
|
||||
one graph for listening and for offline bounce. */
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const clamp = (v, a, b) => v < a ? a : v > b ? b : v;
|
||||
const mulberry = function (seed) {
|
||||
let a = seed | 0;
|
||||
return function () {
|
||||
a = a + 0x6D2B79F5 | 0;
|
||||
let t = Math.imul(a ^ a >>> 15, 1 | a);
|
||||
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
|
||||
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
||||
};
|
||||
};
|
||||
|
||||
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
const sr = ctx.sampleRate;
|
||||
|
||||
const E = {
|
||||
ctx: ctx,
|
||||
sr: sr,
|
||||
cores: [],
|
||||
song: { bpm: 128, bars: 32, stones: [] },
|
||||
weather: { age: 0.34, pressure: 0.52, air: 0.41, heat: 0.28 },
|
||||
rings: 4,
|
||||
muted: false,
|
||||
playing: false,
|
||||
log: [],
|
||||
listeners: { cores: [], log: [], render: [] }
|
||||
};
|
||||
|
||||
/* ————— graph ————— */
|
||||
|
||||
function impulse(c, seconds) {
|
||||
const n = Math.floor(c.sampleRate * seconds);
|
||||
const buf = c.createBuffer(2, n, c.sampleRate);
|
||||
for (let ch = 0; ch < 2; ch++) {
|
||||
const d = buf.getChannelData(ch);
|
||||
for (let i = 0; i < n; i++) {
|
||||
d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / n, 2.6);
|
||||
}
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
function graph(c) {
|
||||
const master = c.createGain();
|
||||
master.gain.value = E.muted ? 0 : 0.85;
|
||||
|
||||
const limiter = c.createDynamicsCompressor();
|
||||
limiter.threshold.value = -7;
|
||||
limiter.knee.value = 8;
|
||||
limiter.ratio.value = 10;
|
||||
limiter.attack.value = 0.004;
|
||||
limiter.release.value = 0.22;
|
||||
|
||||
const analyser = c.createAnalyser();
|
||||
analyser.fftSize = 2048;
|
||||
analyser.smoothingTimeConstant = 0.72;
|
||||
|
||||
const climateIn = c.createGain();
|
||||
const age = c.createBiquadFilter();
|
||||
age.type = 'lowpass';
|
||||
age.Q.value = 0.6;
|
||||
const tilt = c.createBiquadFilter();
|
||||
tilt.type = 'highshelf';
|
||||
tilt.frequency.value = 1800;
|
||||
tilt.gain.value = (macros.warmth - 0.5) * 10;
|
||||
const drive = c.createGain();
|
||||
const shaper = c.createWaveShaper();
|
||||
const curve = new Float32Array(1024);
|
||||
for (let i = 0; i < 1024; i++) {
|
||||
const x = (i * 2) / 1024 - 1;
|
||||
curve[i] = Math.tanh(x * 2.1);
|
||||
}
|
||||
shaper.curve = curve;
|
||||
shaper.oversample = '2x';
|
||||
const post = c.createGain();
|
||||
const dry = c.createGain();
|
||||
const verb = c.createConvolver();
|
||||
verb.buffer = impulse(c, 2.4);
|
||||
const wet = c.createGain();
|
||||
|
||||
climateIn.connect(age);
|
||||
age.connect(tilt);
|
||||
tilt.connect(drive);
|
||||
drive.connect(shaper);
|
||||
shaper.connect(post);
|
||||
post.connect(dry);
|
||||
dry.connect(limiter);
|
||||
post.connect(verb);
|
||||
verb.connect(wet);
|
||||
wet.connect(limiter);
|
||||
limiter.connect(master);
|
||||
master.connect(analyser);
|
||||
analyser.connect(c.destination);
|
||||
|
||||
const veins = [];
|
||||
const pans = [-0.34, -0.16, 0, 0.16, 0.34];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const g = c.createGain();
|
||||
g.gain.value = 0.6;
|
||||
const p = c.createStereoPanner ? c.createStereoPanner() : null;
|
||||
if (p) {
|
||||
p.pan.value = pans[i];
|
||||
g.connect(p);
|
||||
p.connect(climateIn);
|
||||
} else {
|
||||
g.connect(climateIn);
|
||||
}
|
||||
veins.push(g);
|
||||
}
|
||||
const strikeBus = c.createGain();
|
||||
strikeBus.gain.value = 0.8;
|
||||
strikeBus.connect(climateIn);
|
||||
|
||||
// dust: a permanent, quiet hiss in the climate
|
||||
const hissBuf = c.createBuffer(1, Math.floor(c.sampleRate * 2), c.sampleRate);
|
||||
const hd = hissBuf.getChannelData(0);
|
||||
for (let i = 0; i < hd.length; i++) hd[i] = Math.random() * 2 - 1;
|
||||
const hiss = c.createBufferSource();
|
||||
hiss.buffer = hissBuf;
|
||||
hiss.loop = true;
|
||||
const hissF = c.createBiquadFilter();
|
||||
hissF.type = 'highpass';
|
||||
hissF.frequency.value = 1200;
|
||||
const hissGain = c.createGain();
|
||||
hissGain.gain.value = macros.dust * 0.03;
|
||||
hiss.connect(hissF); hissF.connect(hissGain); hissGain.connect(tilt);
|
||||
hiss.start();
|
||||
|
||||
limiter.threshold.value = -20 + macros.threshold * 20;
|
||||
|
||||
return {
|
||||
master: master, limiter: limiter, analyser: analyser, climateIn: climateIn,
|
||||
age: age, tilt: tilt, drive: drive, post: post, dry: dry, wet: wet, verb: verb,
|
||||
veins: veins, strikeBus: strikeBus, hiss: hissGain
|
||||
};
|
||||
}
|
||||
|
||||
const macros = { warmth: 0.58, dust: 0.34, threshold: 0.72 };
|
||||
const G = graph(ctx);
|
||||
const freqData = new Uint8Array(G.analyser.frequencyBinCount);
|
||||
const timeData = new Uint8Array(G.analyser.fftSize);
|
||||
|
||||
function applyClimate(target, w) {
|
||||
const t = target.ctx ? target.ctx.currentTime : 0;
|
||||
target.age.frequency.setTargetAtTime(650 + (1 - w.age) * 11500, t, 0.08);
|
||||
target.drive.gain.setTargetAtTime(1 + w.pressure * 4.2, t, 0.08);
|
||||
target.post.gain.setTargetAtTime(0.72 / (1 + w.pressure * 1.1), t, 0.08);
|
||||
target.wet.gain.setTargetAtTime(0.035 + w.air * 0.52, t, 0.08);
|
||||
target.dry.gain.setTargetAtTime(1 - w.air * 0.28, t, 0.08);
|
||||
}
|
||||
G.ctx = ctx;
|
||||
applyClimate(G, E.weather);
|
||||
|
||||
E.setMacro = function (name, value) {
|
||||
macros[name] = clamp(+value, 0, 1);
|
||||
if (name === 'warmth') G.tilt.gain.setTargetAtTime((macros.warmth - 0.5) * 10, ctx.currentTime, 0.06);
|
||||
if (name === 'dust') G.hiss.gain.setTargetAtTime(macros.dust * 0.03, ctx.currentTime, 0.1);
|
||||
if (name === 'threshold') G.limiter.threshold.setTargetAtTime(-20 + macros.threshold * 20, ctx.currentTime, 0.1);
|
||||
};
|
||||
E.getMacro = function (name) { return macros[name]; };
|
||||
|
||||
/* ————— sound of the material ————— */
|
||||
|
||||
function synth(kind, seed) {
|
||||
const rnd = mulberry(seed * 131 + 7);
|
||||
const dur = kind === 'room' ? 15 : kind === 'rail' ? 12 : kind === 'rain' ? 7.6 : 9.6;
|
||||
const n = Math.floor(dur * sr);
|
||||
const buf = ctx.createBuffer(1, n, sr);
|
||||
const d = buf.getChannelData(0);
|
||||
let peak = 0;
|
||||
|
||||
if (kind === 'stair') {
|
||||
let lp = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const w = Math.random() * 2 - 1;
|
||||
lp += (w - lp) * 0.012;
|
||||
d[i] = lp * 0.9;
|
||||
peak = Math.max(peak, Math.abs(d[i]));
|
||||
}
|
||||
const hits = 11 + Math.floor(rnd() * 5);
|
||||
for (let k = 0; k < hits; k++) {
|
||||
const at = Math.floor((0.04 + rnd() * 0.88) * n);
|
||||
const f = 46 + rnd() * 46;
|
||||
const amp = 0.26 + rnd() * 0.34;
|
||||
const dec = 0.28 + rnd() * 0.75;
|
||||
const max = Math.min(n - at, Math.floor(dec * 3 * sr));
|
||||
for (let i = 0; i < max; i++) {
|
||||
const e = Math.exp(-i / (dec * sr));
|
||||
d[at + i] += Math.sin(TAU() * f * i / sr) * amp * e;
|
||||
}
|
||||
const tn = Math.min(n - at, Math.floor(0.05 * sr));
|
||||
let nlp = 0;
|
||||
for (let i = 0; i < tn; i++) {
|
||||
const w = Math.random() * 2 - 1;
|
||||
nlp += (w - nlp) * 0.2;
|
||||
d[at + i] += nlp * 0.32 * Math.exp(-i / (0.018 * sr));
|
||||
}
|
||||
}
|
||||
} else if (kind === 'rain') {
|
||||
let lp = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const w = Math.random() * 2 - 1;
|
||||
lp += (w - lp) * 0.52;
|
||||
d[i] = (w - lp) * 0.018;
|
||||
}
|
||||
const ticks = 640;
|
||||
for (let k = 0; k < ticks; k++) {
|
||||
const at = Math.floor(rnd() * (n - 800));
|
||||
const amp = 0.04 + rnd() * 0.2;
|
||||
const len = 40 + Math.floor(rnd() * 260);
|
||||
for (let i = 0; i < len; i++) {
|
||||
d[at + i] += (Math.random() * 2 - 1) * amp * Math.exp(-i / (0.0022 * sr));
|
||||
}
|
||||
}
|
||||
for (let k = 0; k < 14; k++) {
|
||||
const at = Math.floor(rnd() * (n - sr));
|
||||
const f = 900 + rnd() * 2800;
|
||||
const amp = 0.025 + rnd() * 0.045;
|
||||
const dec = 0.16 + rnd() * 0.4;
|
||||
for (let i = 0; i < Math.min(n - at, Math.floor(dec * 3 * sr)); i++) {
|
||||
d[at + i] += Math.sin(TAU() * f * i / sr) * amp * Math.exp(-i / (dec * sr));
|
||||
}
|
||||
}
|
||||
} else if (kind === 'room') {
|
||||
let brown = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const w = Math.random() * 2 - 1;
|
||||
brown = (brown + w * 0.02) * 0.999;
|
||||
const swell = 0.55 + 0.45 * Math.sin(TAU() * 0.055 * i / sr + seed);
|
||||
d[i] = brown * 5.2 * swell + Math.sin(TAU() * 41 * i / sr) * 0.012;
|
||||
}
|
||||
} else {
|
||||
let lp = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const w = Math.random() * 2 - 1;
|
||||
lp += (w - lp) * 0.006;
|
||||
d[i] = lp * 1.7;
|
||||
}
|
||||
const squeals = 3 + Math.floor(rnd() * 3);
|
||||
for (let k = 0; k < squeals; k++) {
|
||||
const at = Math.floor((0.1 + rnd() * 0.75) * n);
|
||||
const f0 = 320 + rnd() * 280;
|
||||
const f1 = f0 * (1.15 + rnd() * 0.5);
|
||||
const amp = 0.03 + rnd() * 0.035;
|
||||
const len = Math.min(n - at, Math.floor((0.9 + rnd() * 1.4) * sr));
|
||||
let ph = 0;
|
||||
for (let i = 0; i < len; i++) {
|
||||
const u = i / len;
|
||||
const f = f0 + (f1 - f0) * u;
|
||||
ph += TAU() * f / sr;
|
||||
d[at + i] += Math.sin(ph) * amp * Math.sin(Math.PI * Math.pow(u, 0.6)) * Math.exp(-u * 1.3);
|
||||
}
|
||||
}
|
||||
for (let k = 0; k < 4; k++) {
|
||||
const at = Math.floor(rnd() * 0.9 * n);
|
||||
const f = 38 + rnd() * 30;
|
||||
for (let i = 0; i < Math.min(n - at, Math.floor(1.2 * sr)); i++) {
|
||||
d[at + i] += Math.sin(TAU() * f * i / sr) * 0.16 * Math.exp(-i / (0.4 * sr));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < n; i++) peak = Math.max(peak, Math.abs(d[i]));
|
||||
const norm = 0.58 / (peak || 1);
|
||||
const fade = Math.floor(0.03 * sr);
|
||||
for (let i = 0; i < n; i++) {
|
||||
let g = norm;
|
||||
if (i < fade) g *= i / fade;
|
||||
if (i > n - fade) g *= (n - i) / fade;
|
||||
d[i] *= g;
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
function TAU() { return Math.PI * 2; }
|
||||
|
||||
function envelope(buffer, buckets) {
|
||||
const ch = buffer.getChannelData(0);
|
||||
const n = ch.length;
|
||||
const env = new Float32Array(buckets);
|
||||
const step = n / buckets;
|
||||
for (let i = 0; i < buckets; i++) {
|
||||
let m = 0;
|
||||
const from = Math.floor(i * step);
|
||||
const to = Math.min(n, Math.floor((i + 1) * step));
|
||||
for (let k = from; k < to; k += 2) {
|
||||
const v = Math.abs(ch[k]);
|
||||
if (v > m) m = v;
|
||||
}
|
||||
env[i] = m;
|
||||
}
|
||||
const mx = env.reduce(function (a, b) { return Math.max(a, b); }, 0.0001);
|
||||
for (let i = 0; i < buckets; i++) env[i] = Math.min(1, env[i] / mx);
|
||||
return env;
|
||||
}
|
||||
|
||||
function rmsOf(buffer, startSec, lenSec) {
|
||||
const ch = buffer.getChannelData(0);
|
||||
const a = Math.floor(startSec * buffer.sampleRate);
|
||||
const b = Math.min(ch.length, Math.floor((startSec + lenSec) * buffer.sampleRate));
|
||||
let s = 0, c = 0;
|
||||
for (let i = a; i < b; i += 3) { s += ch[i] * ch[i]; c++; }
|
||||
return c ? Math.sqrt(s / c) : 0;
|
||||
}
|
||||
|
||||
function makeFaces(buffer, variant) {
|
||||
const total = buffer.duration;
|
||||
const env = envelope(buffer, 400);
|
||||
const bounds = [0];
|
||||
for (let i = 1; i < 16; i++) {
|
||||
const base = Math.floor((i / 16) * 400);
|
||||
const span = 10;
|
||||
let best = base, bestV = Infinity;
|
||||
for (let k = Math.max(1, base - span); k < Math.min(399, base + span); k++) {
|
||||
if (env[k] < bestV) { bestV = env[k]; best = k; }
|
||||
}
|
||||
bounds.push(best / 400);
|
||||
}
|
||||
if (variant) {
|
||||
const rnd = mulberry(variant * 997 + 13);
|
||||
for (let i = 1; i < 16; i++) {
|
||||
const shift = (rnd() - 0.5) * 0.045;
|
||||
bounds[i] = clamp(bounds[i] + shift, bounds[i - 1] + 0.004, 0.995);
|
||||
}
|
||||
}
|
||||
bounds.push(1);
|
||||
const faces = [];
|
||||
for (let i = 0; i < 16; i++) {
|
||||
const s = bounds[i] * total;
|
||||
const e = bounds[i + 1] * total;
|
||||
faces.push({ start: s, len: Math.max(0.03, e - s), rms: rmsOf(buffer, s, Math.max(0.03, e - s)) });
|
||||
}
|
||||
const ranked = faces.map(function (f, i) { return i; }).sort(function (a, b) { return faces[b].rms - faces[a].rms; });
|
||||
const cuts = [faces[5].start, faces[10].start, faces[14].start];
|
||||
return { faces: faces, ranked: ranked, cuts: cuts };
|
||||
}
|
||||
|
||||
function addCore(opts) {
|
||||
const buffer = opts.buffer || synth(opts.kind || 'stair', opts.seed || 1);
|
||||
const met = makeFaces(buffer, opts.variant || 0);
|
||||
const core = {
|
||||
id: opts.id || 'core-' + (E.cores.length + 1),
|
||||
name: opts.name || 'untitled core',
|
||||
seal: opts.seal || 'taken',
|
||||
kind: opts.kind || 'stair',
|
||||
seed: opts.seed || 1,
|
||||
dur: buffer.duration,
|
||||
buffer: buffer,
|
||||
env: envelope(buffer, 220),
|
||||
faces: met.faces,
|
||||
ranked: met.ranked,
|
||||
cuts: met.cuts
|
||||
};
|
||||
E.cores.push(core);
|
||||
E.listeners.cores.forEach(function (fn) { fn(core); });
|
||||
return core;
|
||||
}
|
||||
|
||||
function rankFace(coreId, rank) {
|
||||
const c = byId(coreId);
|
||||
if (!c) return 0;
|
||||
return c.ranked[clamp(rank | 0, 0, 15)];
|
||||
}
|
||||
|
||||
function byId(id) {
|
||||
for (let i = 0; i < E.cores.length; i++) if (E.cores[i].id === id) return E.cores[i];
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ————— voices ————— */
|
||||
|
||||
const voices = [];
|
||||
|
||||
function playSlice(core, face, opts) {
|
||||
if (!core || !face) return;
|
||||
const when = opts.when || (ctx.currentTime + 0.01);
|
||||
const pitch = opts.pitch || 0;
|
||||
const rate = Math.pow(2, pitch / 12) * (1 + E.weather.heat * 0.03);
|
||||
const vol = clamp(opts.gain == null ? 0.8 : opts.gain, 0, 2);
|
||||
const dur = Math.max(0.04, face.len / rate);
|
||||
const src = ctx.createBufferSource();
|
||||
src.buffer = core.buffer;
|
||||
src.playbackRate.value = rate;
|
||||
const g = ctx.createGain();
|
||||
const atk = 0.004;
|
||||
const rel = Math.min(0.03, dur * 0.3);
|
||||
g.gain.setValueAtTime(0, when);
|
||||
g.gain.linearRampToValueAtTime(vol, when + atk);
|
||||
g.gain.setValueAtTime(vol, Math.max(when + atk, when + dur - rel));
|
||||
g.gain.linearRampToValueAtTime(0, when + dur);
|
||||
src.connect(g);
|
||||
const target = opts.bus || 0;
|
||||
if (opts.bus) g.connect(opts.bus);
|
||||
else g.connect(G.veins[clamp(target, 0, 4)] || G.veins[0]);
|
||||
src.start(when, clamp(face.start, 0, core.dur - 0.01));
|
||||
src.stop(when + dur + 0.01);
|
||||
const voice = { src: src, g: g, end: when + dur };
|
||||
voices.push(voice);
|
||||
src.onended = function () {
|
||||
const i = voices.indexOf(voice);
|
||||
if (i >= 0) voices.splice(i, 1);
|
||||
};
|
||||
if (voices.length > 30) {
|
||||
const old = voices.shift();
|
||||
try {
|
||||
old.g.gain.cancelScheduledValues(ctx.currentTime);
|
||||
old.g.gain.setTargetAtTime(0, ctx.currentTime, 0.01);
|
||||
old.src.stop(ctx.currentTime + 0.05);
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
function strike(coreId, faceIdx, opts) {
|
||||
opts = opts || {};
|
||||
resume();
|
||||
const core = byId(coreId);
|
||||
if (!core) return;
|
||||
const face = core.faces[faceIdx];
|
||||
if (!face) return;
|
||||
playSlice(core, face, {
|
||||
pitch: opts.pitch || 0,
|
||||
gain: opts.gain || 0.9,
|
||||
bus: G.strikeBus
|
||||
});
|
||||
if (opts.ghost) {
|
||||
const when = ctx.currentTime + 0.02;
|
||||
const src = ctx.createBufferSource();
|
||||
src.buffer = core.buffer;
|
||||
src.playbackRate.value = Math.pow(2, (opts.pitch || 0) / 12) * 0.5;
|
||||
const f = ctx.createBiquadFilter();
|
||||
f.type = 'lowpass';
|
||||
f.frequency.value = 900;
|
||||
const g = ctx.createGain();
|
||||
const dur = Math.min(1.2, face.len * 2.4);
|
||||
g.gain.setValueAtTime(0, when);
|
||||
g.gain.linearRampToValueAtTime(0.09, when + 0.05);
|
||||
g.gain.exponentialRampToValueAtTime(0.001, when + dur);
|
||||
src.connect(f); f.connect(g); g.connect(G.strikeBus);
|
||||
src.start(when, clamp(face.start, 0, core.dur - 0.01));
|
||||
src.stop(when + dur + 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
function stopVoices() {
|
||||
const now = ctx.currentTime;
|
||||
voices.splice(0).forEach(function (v) {
|
||||
try {
|
||||
v.g.gain.cancelScheduledValues(now);
|
||||
v.g.gain.setTargetAtTime(0, now, 0.015);
|
||||
v.src.stop(now + 0.08);
|
||||
} catch (e) {}
|
||||
});
|
||||
}
|
||||
|
||||
/* ————— song & scheduler ————— */
|
||||
|
||||
let events = [];
|
||||
let pass = 0, cursor = 0, nextTime = Infinity, timer = null;
|
||||
let transportStart = 0;
|
||||
let pausedPos = 7.5;
|
||||
|
||||
function barSec() { return 60 / E.song.bpm * 4; }
|
||||
function bps() { return E.song.bpm / 60 / 4; }
|
||||
function loopDur() { return E.song.bars * barSec(); }
|
||||
|
||||
function compile() {
|
||||
events = [];
|
||||
E.song.stones.forEach(function (s) {
|
||||
const core = byId(s.coreId);
|
||||
if (!core) return;
|
||||
const face = core.faces[s.face | 0];
|
||||
if (!face) return;
|
||||
const a = clamp(+s.a || 1, 1, E.song.bars);
|
||||
const b = clamp(+s.b || a + 1, a + 0.01, E.song.bars);
|
||||
const ring = Math.max(0, +s.ring || 0);
|
||||
const pitch = +s.pitch || 0;
|
||||
const gain = s.gain == null ? 0.8 : +s.gain;
|
||||
const vein = clamp(+s.vein || 0, 0, 4);
|
||||
if (ring > 0) {
|
||||
let cycle = 0;
|
||||
const endBar = b >= E.song.bars - 1e-6 ? b + 1e-6 : b - 1e-6;
|
||||
for (let bar = a; bar < endBar; bar += ring) {
|
||||
events.push({ bar: bar, core: core, face: face, pitch: pitch, gain: gain, vein: vein, cycle: cycle++ });
|
||||
}
|
||||
} else {
|
||||
events.push({ bar: a, core: core, face: face, pitch: pitch, gain: gain, vein: vein, cycle: 0 });
|
||||
}
|
||||
});
|
||||
events.sort(function (p, q) { return p.bar - q.bar; });
|
||||
}
|
||||
|
||||
function resetScheduler() {
|
||||
pass = 0; cursor = 0;
|
||||
nextTime = events.length
|
||||
? transportStart + (events[0].bar - 1) * barSec()
|
||||
: Infinity;
|
||||
}
|
||||
|
||||
function tick() {
|
||||
if (!E.playing || !events.length) return;
|
||||
const now = ctx.currentTime;
|
||||
const until = now + 0.18;
|
||||
let guard = 0;
|
||||
while (nextTime < until && guard++ < 800) {
|
||||
if (nextTime >= now - 0.02) {
|
||||
const ev = events[cursor];
|
||||
const growth = 1 + ev.cycle * 0.0006 * E.rings;
|
||||
playSlice(ev.core, ev.face, {
|
||||
when: nextTime,
|
||||
pitch: ev.pitch + ev.cycle * 0.01 * E.rings,
|
||||
gain: ev.gain * (1 - Math.min(0.35, ev.cycle * 0.004 * E.rings)) * growth,
|
||||
vein: ev.vein
|
||||
});
|
||||
}
|
||||
cursor++;
|
||||
if (cursor >= events.length) { cursor = 0; pass++; }
|
||||
nextTime = transportStart + pass * loopDur() + (events[cursor].bar - 1) * barSec();
|
||||
}
|
||||
}
|
||||
|
||||
function resume() {
|
||||
if (ctx.state === 'suspended') ctx.resume();
|
||||
}
|
||||
|
||||
E.play = function () {
|
||||
resume();
|
||||
if (E.playing) return;
|
||||
E.playing = true;
|
||||
transportStart = ctx.currentTime - (pausedPos - 1) / bps() + 0.06;
|
||||
resetScheduler();
|
||||
clearInterval(timer);
|
||||
timer = setInterval(tick, 25);
|
||||
tick();
|
||||
};
|
||||
|
||||
E.pause = function () {
|
||||
if (!E.playing) return;
|
||||
pausedPos = E.getPos();
|
||||
E.playing = false;
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
stopVoices();
|
||||
};
|
||||
|
||||
E.stop = function () {
|
||||
E.playing = false;
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
pausedPos = 1;
|
||||
stopVoices();
|
||||
};
|
||||
|
||||
E.seek = function (pos) {
|
||||
pausedPos = clamp(pos, 1, E.song.bars);
|
||||
if (E.playing) {
|
||||
stopVoices();
|
||||
transportStart = ctx.currentTime - (pausedPos - 1) / bps() + 0.04;
|
||||
resetScheduler();
|
||||
}
|
||||
};
|
||||
|
||||
E.getPos = function () {
|
||||
if (!E.playing) return pausedPos;
|
||||
const p = 1 + (ctx.currentTime - transportStart) * bps();
|
||||
const loop = E.song.bars;
|
||||
return ((p - 1) % loop + loop) % loop + 1;
|
||||
};
|
||||
|
||||
E.setBpm = function (bpm) {
|
||||
const pos = E.getPos();
|
||||
E.song.bpm = clamp(bpm, 40, 220);
|
||||
if (E.playing) {
|
||||
transportStart = ctx.currentTime - (pos - 1) / bps();
|
||||
resetScheduler();
|
||||
}
|
||||
pausedPos = pos;
|
||||
};
|
||||
|
||||
E.setSong = function (stones) {
|
||||
E.song.stones = (stones || []).slice();
|
||||
compile();
|
||||
if (E.playing) {
|
||||
transportStart = ctx.currentTime - (E.getPos() - 1) / bps();
|
||||
resetScheduler();
|
||||
}
|
||||
};
|
||||
|
||||
E.setWeather = function (w) {
|
||||
Object.assign(E.weather, w || {});
|
||||
applyClimate(G, E.weather);
|
||||
};
|
||||
|
||||
E.setRings = function (n) { E.rings = clamp(n, 0, 24); };
|
||||
|
||||
E.setVeinGain = function (i, v) {
|
||||
const g = G.veins[clamp(i, 0, 4)];
|
||||
if (g) g.gain.setTargetAtTime(clamp(v, 0, 1.4), ctx.currentTime, 0.03);
|
||||
};
|
||||
|
||||
E.setMuted = function (muted) {
|
||||
E.muted = !!muted;
|
||||
G.master.gain.setTargetAtTime(E.muted ? 0 : 0.85, ctx.currentTime, 0.08);
|
||||
};
|
||||
|
||||
E.getMuted = function () { return E.muted; };
|
||||
E.isPlaying = function () { return E.playing; };
|
||||
E.getBpm = function () { return E.song.bpm; };
|
||||
E.getEvents = function () { return events; };
|
||||
E.pausedPos = function () { return pausedPos; };
|
||||
|
||||
/* ————— analysis ————— */
|
||||
|
||||
E.strike = strike;
|
||||
|
||||
E.read = function () {
|
||||
G.analyser.getByteFrequencyData(freqData);
|
||||
G.analyser.getByteTimeDomainData(timeData);
|
||||
return { freq: freqData, time: timeData, sr: sr };
|
||||
};
|
||||
|
||||
/* ————— cores ————— */
|
||||
|
||||
E.byId = byId;
|
||||
E.rankFace = rankFace;
|
||||
E.refracture = function (coreId) {
|
||||
const core = byId(coreId);
|
||||
if (!core) return null;
|
||||
core.variant = (core.variant || 0) + 1;
|
||||
const met = makeFaces(core.buffer, core.variant);
|
||||
core.faces = met.faces;
|
||||
core.ranked = met.ranked;
|
||||
core.cuts = met.cuts;
|
||||
compile();
|
||||
if (E.playing) {
|
||||
transportStart = ctx.currentTime - (E.getPos() - 1) / bps();
|
||||
resetScheduler();
|
||||
}
|
||||
return core;
|
||||
};
|
||||
|
||||
E.newCore = function (opts) {
|
||||
opts = opts || {};
|
||||
const kinds = ['stair', 'rain', 'room', 'rail'];
|
||||
const kind = opts.kind || kinds[E.cores.length % kinds.length];
|
||||
return addCore({
|
||||
name: opts.name || kind + ' core ' + (E.cores.length + 1),
|
||||
seal: opts.seal || 'taken',
|
||||
kind: kind,
|
||||
seed: opts.seed || (Math.floor(Math.random() * 900) + 17),
|
||||
id: opts.id
|
||||
});
|
||||
};
|
||||
|
||||
E.addBufferCore = function (opts) {
|
||||
return addCore({
|
||||
name: opts.name,
|
||||
seal: opts.seal || 'taken',
|
||||
kind: 'import',
|
||||
buffer: opts.buffer,
|
||||
id: opts.id
|
||||
});
|
||||
};
|
||||
|
||||
E.onCores = function (fn) { E.listeners.cores.push(fn); };
|
||||
E.onLog = function (fn) { E.listeners.log.push(fn); };
|
||||
|
||||
E.addLog = function (tool, detail, seal, by) {
|
||||
E.log.unshift({ t: nowTime(), tool: tool, detail: detail, seal: seal || '', by: by || 'hand' });
|
||||
if (E.log.length > 40) E.log.pop();
|
||||
E.listeners.log.forEach(function (fn) { fn(E.log); });
|
||||
};
|
||||
|
||||
function nowTime() {
|
||||
const d = new Date();
|
||||
return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
|
||||
}
|
||||
|
||||
/* ————— import & record ————— */
|
||||
|
||||
E.importFile = function (file) {
|
||||
return file.arrayBuffer().then(function (ab) {
|
||||
return ctx.decodeAudioData(ab);
|
||||
}).then(function (buffer) {
|
||||
const name = file.name.replace(/\.[a-z0-9]+$/i, '').slice(0, 34) || 'imported';
|
||||
const core = addCore({ name: name, seal: 'taken', kind: 'import', buffer: buffer });
|
||||
E.addLog('cores.extract', name, 'taken', 'hand');
|
||||
return core;
|
||||
});
|
||||
};
|
||||
|
||||
E.recordRoom = function (seconds) {
|
||||
seconds = seconds || 6;
|
||||
return navigator.mediaDevices.getUserMedia({ audio: true }).then(function (stream) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
const types = ['audio/webm;codecs=opus', 'audio/webm', ''];
|
||||
let rec = null;
|
||||
for (let i = 0; i < types.length; i++) {
|
||||
try { rec = new MediaRecorder(stream, types[i] ? { mimeType: types[i] } : undefined); break; } catch (e) {}
|
||||
}
|
||||
if (!rec) { stream.getTracks().forEach(function (t) { t.stop(); }); reject(new Error('no recorder')); return; }
|
||||
const chunks = [];
|
||||
rec.ondataavailable = function (e) { if (e.data && e.data.size) chunks.push(e.data); };
|
||||
rec.onstop = function () {
|
||||
stream.getTracks().forEach(function (t) { t.stop(); });
|
||||
const blob = new Blob(chunks, { type: chunks[0] ? chunks[0].type : 'audio/webm' });
|
||||
blob.arrayBuffer().then(function (ab) { return ctx.decodeAudioData(ab); }).then(function (buffer) {
|
||||
const name = 'room, ' + nowTime();
|
||||
const core = addCore({ name: name, seal: 'taken', kind: 'import', buffer: buffer });
|
||||
E.addLog('cores.extract', name, 'taken', 'hand');
|
||||
resolve(core);
|
||||
}).catch(reject);
|
||||
};
|
||||
rec.start();
|
||||
setTimeout(function () { if (rec.state !== 'inactive') rec.stop(); }, seconds * 1000);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/* ————— render ————— */
|
||||
|
||||
function encodeWav(buffer) {
|
||||
const channels = Math.min(2, buffer.numberOfChannels);
|
||||
const len = buffer.length;
|
||||
const bytes = 44 + len * channels * 2;
|
||||
const ab = new ArrayBuffer(bytes);
|
||||
const view = new DataView(ab);
|
||||
function str(off, s) { for (let i = 0; i < s.length; i++) view.setUint8(off + i, s.charCodeAt(i)); }
|
||||
str(0, 'RIFF');
|
||||
view.setUint32(4, bytes - 8, true);
|
||||
str(8, 'WAVE');
|
||||
str(12, 'fmt ');
|
||||
view.setUint32(16, 16, true);
|
||||
view.setUint16(20, 1, true);
|
||||
view.setUint16(22, channels, true);
|
||||
view.setUint32(24, buffer.sampleRate, true);
|
||||
view.setUint32(28, buffer.sampleRate * channels * 2, true);
|
||||
view.setUint16(32, channels * 2, true);
|
||||
view.setUint16(34, 16, true);
|
||||
str(36, 'data');
|
||||
view.setUint32(40, len * channels * 2, true);
|
||||
const chans = [];
|
||||
for (let c = 0; c < channels; c++) chans.push(buffer.getChannelData(c));
|
||||
let off = 44;
|
||||
for (let i = 0; i < len; i++) {
|
||||
for (let c = 0; c < channels; c++) {
|
||||
const s = clamp(chans[c][i], -1, 1);
|
||||
view.setInt16(off, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
|
||||
off += 2;
|
||||
}
|
||||
}
|
||||
return new Blob([ab], { type: 'audio/wav' });
|
||||
}
|
||||
|
||||
E.render = function () {
|
||||
const duration = loopDur() + 2.4;
|
||||
const off = new OfflineAudioContext(2, Math.ceil(duration * sr), sr);
|
||||
const go = graph(off);
|
||||
go.ctx = off;
|
||||
Object.assign(go, { ctx: off });
|
||||
applyClimate(go, E.weather);
|
||||
go.tilt.gain.value = (macros.warmth - 0.5) * 10;
|
||||
go.hiss.gain.value = macros.dust * 0.03;
|
||||
go.limiter.threshold.value = -20 + macros.threshold * 20;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
go.veins[i].gain.value = G.veins[i].gain.value;
|
||||
}
|
||||
off.destination.channelCount = 2;
|
||||
const startAt = off.currentTime;
|
||||
events.forEach(function (ev) {
|
||||
const when = startAt + (ev.bar - 1) * barSec() + 0.01;
|
||||
const rate = Math.pow(2, (ev.pitch + ev.cycle * 0.01 * E.rings) / 12) * (1 + E.weather.heat * 0.03);
|
||||
const dur = Math.max(0.04, ev.face.len / rate);
|
||||
const src = off.createBufferSource();
|
||||
src.buffer = ev.core.buffer;
|
||||
src.playbackRate.value = rate;
|
||||
const g = off.createGain();
|
||||
const vol = clamp(ev.gain * (1 - Math.min(0.35, ev.cycle * 0.004 * E.rings)), 0, 2);
|
||||
g.gain.setValueAtTime(0, when);
|
||||
g.gain.linearRampToValueAtTime(vol, when + 0.004);
|
||||
g.gain.setValueAtTime(vol, Math.max(when + 0.004, when + dur - 0.03));
|
||||
g.gain.linearRampToValueAtTime(0, when + dur);
|
||||
src.connect(g);
|
||||
g.connect(go.veins[ev.vein] || go.veins[0]);
|
||||
src.start(when, clamp(ev.face.start, 0, ev.core.dur - 0.01));
|
||||
src.stop(when + dur + 0.01);
|
||||
});
|
||||
return off.startRendering().then(function (buf) {
|
||||
return encodeWav(buf);
|
||||
});
|
||||
};
|
||||
|
||||
E.download = function () {
|
||||
return E.render().then(function (blob) {
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = 'zhila-descent-04.wav';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(function () { URL.revokeObjectURL(a.href); }, 20000);
|
||||
return blob;
|
||||
});
|
||||
};
|
||||
|
||||
/* ————— default cores ————— */
|
||||
|
||||
addCore({ id: 'stairwell-03', name: 'stairwell, 3rd floor', seal: 'taken', kind: 'stair', seed: 3 });
|
||||
addCore({ id: 'rain-metal', name: 'rain on metal', seal: 'found', kind: 'rain', seed: 7 });
|
||||
addCore({ id: 'room-04', name: 'room tone 04', seal: 'gift', kind: 'room', seed: 11 });
|
||||
addCore({ id: 'rail-02', name: 'rail, night', seal: 'loan', kind: 'rail', seed: 5 });
|
||||
|
||||
E.log = [
|
||||
{ t: '21:52', tool: 'cores.extract', detail: 'stairwell, 3rd floor', seal: 'taken', by: 'hand' },
|
||||
{ t: '21:53', tool: 'faces.fracture', detail: 'stairwell-03 · 16 faces', seal: '', by: 'hand' },
|
||||
{ t: '21:55', tool: 'rings.grow', detail: 'ring 04 · young', seal: '', by: 'mcp' }
|
||||
];
|
||||
|
||||
window.ZHILA_ENGINE = E;
|
||||
})();
|
||||
Reference in New Issue
Block a user