P0: Rust engine, wire daemon and audio-reactive descent surface

This commit is contained in:
Emil
2026-11-01 00:36:04 +03:00
parent cd967cced4
commit db4368efae
68 changed files with 19507 additions and 3 deletions
+825
View File
@@ -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;
})();