/* 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; })();