Files
zhila/reference/demo/app.js
T

1765 lines
62 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ZHILA · the machine
the choir above, hands below. the surface listens. */
(function () {
'use strict';
const $ = (s, r = document) => r.querySelector(s);
const $$ = (s, r = document) => Array.prototype.slice.call(r.querySelectorAll(s));
const clamp = (v, a, b) => v < a ? a : v > b ? b : v;
const TAU = Math.PI * 2;
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const frac = v => v - Math.floor(v);
const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const state = {
pos: 7.5,
playing: false,
bpm: 128,
mode: 'choir',
sound: true,
rec: false,
generating: false,
input: 'drone',
calls: 0,
selClip: null
};
/* ————— the material ————— */
const LANES = [
{
name: 'bass', gain: 0.72, clips: [
{ a: 1, b: 9, name: 'rubble', seal: 'taken', core: 'stairwell-03', faceRank: 0, ring: 1, pitch: -2, gain: 0.9 },
{ a: 9, b: 17, name: 'footing', seal: 'taken', core: 'stairwell-03', faceRank: 0, ring: 1, pitch: -2, gain: 0.85 },
{ a: 17, b: 25, name: 'stone', seal: 'found', core: 'stairwell-03', faceRank: 1, ring: 1, pitch: -5, gain: 0.9 },
{ a: 25, b: 32, name: 'keystone', seal: 'found', core: 'stairwell-03', faceRank: 1, ring: 1, pitch: -5, gain: 0.85 }
]
},
{
name: 'crack', gain: 0.54, clips: [
{ a: 1, b: 9, name: 'seam', seal: 'taken', core: 'rain-metal', faceRank: 0, ring: 0.5, pitch: 7, gain: 0.6 },
{ a: 9, b: 17, name: 'grit', seal: 'taken', core: 'rain-metal', faceRank: 0, ring: 0.5, pitch: 7, gain: 0.5 },
{ a: 17, b: 25, name: 'splinter', seal: 'found', core: 'rain-metal', faceRank: 4, ring: 0.5, pitch: 12, gain: 0.55 },
{ a: 25, b: 32, name: 'collapse', seal: 'unknown', core: 'rain-metal', faceRank: 4, ring: 0.5, pitch: 12, gain: 0.6 }
]
},
{
name: 'voice', gain: 0.66, clips: [
{ a: 1, b: 17, name: 'choir 06', seal: 'gift', core: 'room-04', faceRank: 0, ring: 1, pitch: -12, gain: 0.55 },
{ a: 17, b: 32, name: 'response', seal: 'gift', core: 'room-04', faceRank: 0, ring: 1, pitch: -12, gain: 0.6 }
]
},
{
name: 'noise', gain: 0.43, clips: [
{ a: 1, b: 17, name: 'breath', seal: 'found', core: 'rain-metal', faceRank: 6, ring: 4, pitch: 5, gain: 0.5 },
{ a: 17, b: 32, name: 'wind', seal: 'found', core: 'rain-metal', faceRank: 6, ring: 4, pitch: 5, gain: 0.55 }
]
},
{
name: 'silt', gain: 0.37, clips: [
{ a: 9, b: 12, name: 'dust', seal: 'loan', core: 'rail-02', faceRank: 0, ring: 1, pitch: -3, gain: 0.8 },
{ a: 19, b: 28, name: 'echo', seal: 'unknown', core: 'rail-02', faceRank: 3, ring: 4, pitch: -7, gain: 0.7 }
]
}
];
const SECTIONS = [
{ at: 1, name: 'entry' },
{ at: 9, name: 'descent' },
{ at: 17, name: 'choir' },
{ at: 25, name: 'collapse' }
];
const NEW_NAMES = ['silt', 'ridge', 'hollow', 'ember', 'fall', 'shard'];
const NOTE_NAMES = ['A', 'G', 'F', 'E', 'D', 'C', 'B', 'A'];
let clipSeq = 0;
let lastSection = '';
/* ————— refs ————— */
const prompt = $('#prompt');
const invocation = $('#invocation');
const voidBtn = $('#void');
const voidWord = $('#void-word');
const logEl = $('#log');
const stateEl = $('#agent-state');
const stateText = $('#agent-state-text');
const netText = $('#net-text');
const stage = $('#stage');
const svg = $('#nave');
const latticeEl = $('#lattice');
const fadersEl = $('#faders');
const halo = $('#halo');
const hctx = halo.getContext('2d');
const breath = $('#breath');
const bctx = breath.getContext('2d');
const fileIn = $('#file-in');
const scope = $('#scope');
const sctx = scope.getContext('2d');
let NAVE = null;
let phLine = null, phGlow = null, phMark = null;
let haloW = 0, haloH = 0, voidR = 0;
let eyeCX = 0, eyeCY = 0;
let breathW = 0, breathH = 0;
const BS = 0.6;
let peak = 0.2;
let lastClock = '';
let lastBarKey = -1;
let lastLv = -1;
let scopeW = 0, scopeH = 0;
let hotLane = -1;
/* ————— clock ————— */
function clockTime() {
const d = new Date();
return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
}
/* ————— agent console (api + mcp) ————— */
function scrollLog() {
logEl.scrollTop = logEl.scrollHeight;
}
function setAgentState(text, thinking) {
stateText.textContent = text;
stateEl.classList.toggle('is-thinking', !!thinking);
}
function argsHTML(pairs) {
return '<span class="p">{ </span>' + pairs.map(function (pair) {
const val = pair[2] ? '<span class="v">' + pair[1] + '</span>'
: '<span class="v">"' + pair[1] + '"</span>';
return '<span class="k">"' + pair[0] + '"</span><span class="p">: </span>' + val;
}).join('<span class="p">, </span>') + '<span class="p"> }</span>';
}
function addYou(text) {
const li = document.createElement('li');
li.className = 'call call--you';
li.innerHTML = '<span class="call-t"></span><p></p>';
li.querySelector('.call-t').textContent = clockTime();
li.querySelector('p').textContent = '\u201C' + text + '\u201D';
logEl.appendChild(li);
scrollLog();
}
function addCall(opts) {
const li = document.createElement('li');
li.className = 'call call--tool' + (opts.src === 'mcp' ? ' call--mcp' : '');
li.innerHTML =
'<div class="call-head">' +
'<span class="call-t">' + clockTime() + '</span>' +
'<span class="call-name"></span>' +
'<span class="call-src">' + opts.src + '</span>' +
'<span class="call-ms">' + opts.ms + '</span>' +
'<button class="call-undo" type="button">undo</button>' +
'</div>' +
'<pre class="call-args"></pre>';
li.querySelector('.call-name').textContent = opts.tool;
li.querySelector('.call-args').innerHTML = argsHTML(opts.args);
const btn = li.querySelector('.call-undo');
if (opts.undo) {
btn.addEventListener('click', function () {
if (btn.dataset.done) return;
btn.dataset.done = '1';
btn.textContent = 'undone';
btn.style.borderBottomColor = 'transparent';
opts.undo();
});
} else {
btn.remove();
}
logEl.appendChild(li);
scrollLog();
}
function chooseVerb(q) {
const s = q.toLowerCase();
if (/\b(remove|only|leave|strip|mute|kill|clear|erase)\b/.test(s)) return 'remove';
if (/\b(level|loud|quiet|gain|db|balance|raise|lower)\b/.test(s)) return 'level';
return 'create';
}
function askAgent(text) {
if (window.ZHILA_AGENT) window.ZHILA_AGENT.say(text);
else invoke(text);
}
function invoke(text) {
if (state.generating) return;
const q = (typeof text === 'string' ? text : prompt.value).trim();
if (!q) { prompt.focus(); return; }
if (state.mode === 'hand') setMode('choir');
addYou(q);
prompt.value = '';
state.generating = true;
setAgentState('thinking', true);
voidBtn.classList.add('is-pulsed');
const verb = chooseVerb(q);
const t0 = performance.now();
const wait = 780 + Math.random() * 720;
window.setTimeout(function () {
const ms = ((performance.now() - t0) / 1000).toFixed(1) + 's';
state.generating = false;
voidBtn.classList.remove('is-pulsed');
setAgentState(state.mode === 'hand' ? 'muted' : 'listening', false);
if (verb === 'level') {
const li = state.calls % LANES.length;
const lane = LANES[li];
const before = lane.gain;
const db = Math.round((-3 - Math.random() * 9) * 10) / 10;
lane.gain = clamp(Math.pow(10, db / 20), 0.04, 1);
paintChannel(li);
addCall({
tool: 'level.set', src: 'api', ms: ms,
args: [['vein', lane.name], ['db', db, true]],
undo: function () { lane.gain = before; paintChannel(li); }
});
} else if (verb === 'remove') {
pushUndo();
let lane = LANES[0];
LANES.forEach(function (l) { if (l.clips.length > lane.clips.length) lane = l; });
const clip = lane.clips.pop();
if (clip) {
addCall({
tool: 'layers.remove', src: 'api', ms: ms,
args: [['vein', lane.name], ['bars', '[' + clip.a + ', ' + clip.b + ']', true]],
undo: function () { lane.clips.push(clip); buildNave(); commitSong(); scheduleSave(); }
});
}
buildNave();
commitSong();
scheduleSave();
if (E) E.addLog('layers.remove', lane.name + ', bar ' + (clip ? clip.a : '—'), '', 'api');
} else {
const pool = E ? E.cores : [];
const core = pool.length ? pool[state.calls % pool.length] : null;
const face = core && E ? E.rankFace(core.id, 0) : 0;
const density = shapeValue('s-density');
const depth = shapeValue('s-depth');
const grain = shapeValue('s-grain');
const ring = density > 0.66 ? 1 : density > 0.33 ? 2 : 4;
const pitch = -Math.round(depth * 12);
const name = NEW_NAMES[state.calls % NEW_NAMES.length];
const res = addStoneClip(name, 'taken', {
core: core ? core.id : undefined, face: face, ring: ring, pitch: pitch,
gain: 0.55 + grain * 0.45, actor: 'api'
});
addCall({
tool: 'layers.create', src: 'api', ms: ms,
args: [['core', core ? core.id : '—'], ['face', face, true], ['ring', ring, true], ['bars', '[' + res.start + ', ' + res.end + ']', true]],
undo: function () {
const f = findClip(res.clip.id);
if (f) {
f.lane.clips.splice(f.i, 1);
buildNave();
commitSong();
scheduleSave();
}
}
});
}
state.calls++;
}, wait);
}
invocation.addEventListener('submit', function (e) {
e.preventDefault();
const q = prompt.value.trim();
if (!q) { prompt.focus(); return; }
prompt.value = '';
askAgent(q);
});
invocation.addEventListener('pointerdown', function (e) {
if (state.mode === 'hand') {
setMode('choir');
prompt.focus();
if (e.target !== prompt) e.preventDefault();
}
});
voidBtn.addEventListener('click', function () {
if (prompt.value.trim()) { const q = prompt.value.trim(); prompt.value = ''; askAgent(q); }
else prompt.focus();
voidBtn.classList.remove('is-pulsed');
void requestAnimationFrame(function () { voidBtn.classList.add('is-pulsed'); });
});
/* ————— mode ————— */
function setMode(mode) {
state.mode = mode;
document.body.dataset.mode = mode;
$$('.mode-btn').forEach(function (b) {
const on = b.dataset.modeBtn === mode;
b.classList.toggle('is-on', on);
b.setAttribute('aria-pressed', String(on));
});
netText.textContent = mode === 'choir' ? ((window.ZHILA_AGENT && window.ZHILA_AGENT.isLive()) ? 'live' : 'local') : 'idle';
setAgentState(mode === 'choir' ? (state.generating ? 'thinking' : 'listening') : 'muted', state.generating);
prompt.disabled = mode === 'hand';
if (mode === 'choir') {
if (prompt.offsetParent !== null) prompt.focus({ preventScroll: true });
} else {
prompt.blur();
}
}
$$('.mode-btn').forEach(function (b) {
b.addEventListener('click', function () { setMode(b.dataset.modeBtn); });
});
/* ————— the nave (arrangement) ————— */
function mulberry(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;
};
}
function clipSeal(x, y, kind) {
let inner = '';
if (kind === 'taken') {
inner = '<path d="M' + (x - 1.5) + ' ' + (y - 1.5) + 'L' + (x + 1.5) + ' ' + (y + 1.5) + 'M' + (x + 1.5) + ' ' + (y - 1.5) + 'L' + (x - 1.5) + ' ' + (y + 1.5) + '" stroke="rgba(232,64,42,.8)" stroke-width=".8" fill="none"/>';
} else if (kind === 'found') {
inner = '<circle cx="' + x + '" cy="' + y + '" r="1.1" fill="rgba(232,64,42,.85)"/>';
} else if (kind === 'loan') {
inner = '<path d="M' + (x - 1.4) + ' ' + (y + 1.4) + 'L' + (x + 1.4) + ' ' + (y - 1.4) + '" stroke="rgba(232,64,42,.8)" stroke-width=".8"/>';
} else if (kind === 'gift') {
inner = '<circle cx="' + x + '" cy="' + y + '" r="1" fill="none" stroke="rgba(239,228,212,.7)" stroke-width=".7"/>';
}
return '<circle cx="' + x + '" cy="' + y + '" r="3.1" fill="none" stroke="rgba(155,133,121,.45)" stroke-width=".65"/>' + inner;
}
function stoneOutline(corners, seed) {
const rnd = mulberry(seed);
const pts = [];
for (let e = 0; e < 4; e++) {
const a = corners[e], b = corners[(e + 1) % 4];
pts.push([a[0] + (rnd() - 0.5) * 2.4, a[1] + (rnd() - 0.5) * 2.4]);
pts.push([
(a[0] + b[0]) / 2 + (rnd() - 0.5) * 7,
(a[1] + b[1]) / 2 + (rnd() - 0.5) * 7
]);
}
return pts;
}
function ptsStr(pts) {
return pts.map(function (p) { return p[0].toFixed(1) + ',' + p[1].toFixed(1); }).join(' ');
}
function addStoneClip(name, seal, opts) {
opts = opts || {};
const actor = opts.actor || 'hand';
pushUndo();
let lane = LANES[0];
LANES.forEach(function (l) { if (l.clips.length < lane.clips.length) lane = l; });
const start = clamp(Math.ceil(state.pos) + 1, 1, 28);
let core = opts.core, face = opts.face, ring = opts.ring || 0;
if (!core && E) {
const pick = E.cores[Math.floor(Math.random() * E.cores.length)];
if (pick) { core = pick.id; face = E.rankFace(pick.id, 0); ring = 0; }
}
const span = ring ? 8 : 4;
const clip = {
a: start, b: Math.min(32, start + span),
name: name || 'stone', seal: seal || 'taken', id: ++clipSeq,
core: core, face: face, ring: ring, pitch: opts.pitch || 0,
gain: opts.gain == null ? 0.85 : opts.gain
};
lane.clips.push(clip);
buildNave(clip.id);
commitSong();
scheduleSave();
if (E) E.addLog('nave.place', (name || 'stone') + ' → ' + lane.name + ', bar ' + start, clip.seal, actor);
return { clip: clip, lane: lane.name, start: start, end: clip.b };
}
function buildNave(newClipId) {
if (!stage) return;
const W = stage.clientWidth;
const H = stage.clientHeight;
if (!W || !H) return;
const horizon = H * 0.28;
const band = W < 700 ? 18 : 22;
const bottom = H - band;
const vpX = W * 0.5;
const halfW = Math.min(W * 0.4, 460);
const sOfBar = function (b) { return 1 - Math.pow(clamp((b - 1) / 31, 0, 1), 1.16); };
const yAt = function (s) { return horizon + (bottom - horizon) * s; };
const qAt = function (s) { return 0.3 + 0.7 * Math.pow(s, 1.08); };
const xAt = function (c, s) { return vpX + c * halfW * qAt(s); };
NAVE = { W: W, H: H, horizon: horizon, bottom: bottom, vpX: vpX, halfW: halfW, sOfBar: sOfBar, yAt: yAt, xAt: xAt };
svg.setAttribute('viewBox', '0 0 ' + W + ' ' + H);
svg.setAttribute('width', W);
svg.setAttribute('height', H);
const edges = [-1, -0.6, -0.2, 0.2, 0.6, 1];
const centers = [-0.8, -0.4, 0, 0.4, 0.8];
const cw = 0.172;
let out = '';
/* the road: a lit ground, then five veins */
const roadPoly = [
xAt(-1, 0).toFixed(1) + ',' + yAt(0).toFixed(1),
xAt(1, 0).toFixed(1) + ',' + yAt(0).toFixed(1),
xAt(1, 1).toFixed(1) + ',' + yAt(1).toFixed(1),
xAt(-1, 1).toFixed(1) + ',' + yAt(1).toFixed(1)
].join(' ');
out += '<defs>';
out += '<linearGradient id="roadFade" x1="0" y1="0" x2="0" y2="1">';
out += '<stop offset="0" stop-color="#0a0605" stop-opacity=".14"/>';
out += '<stop offset=".55" stop-color="#0a0605" stop-opacity=".22"/>';
out += '<stop offset="1" stop-color="#0a0605" stop-opacity=".34"/>';
out += '</linearGradient>';
out += '<linearGradient id="roadFloor" x1="0" y1="1" x2="0" y2="0">';
out += '<stop offset="0" stop-color="#e8432a" stop-opacity=".26"/>';
out += '<stop offset=".34" stop-color="#e8432a" stop-opacity="0"/>';
out += '</linearGradient>';
out += '</defs>';
out += '<polygon class="road-base" points="' + roadPoly + '"/>';
out += '<polygon class="road-floor" points="' + roadPoly + '"/>';
/* lanes: hit areas and the hover glow */
LANES.forEach(function (lane, i) {
const c0 = edges[i], c1 = edges[i + 1];
const poly = [
xAt(c0, 0).toFixed(1) + ',' + yAt(0).toFixed(1),
xAt(c1, 0).toFixed(1) + ',' + yAt(0).toFixed(1),
xAt(c1, 1).toFixed(1) + ',' + yAt(1).toFixed(1),
xAt(c0, 1).toFixed(1) + ',' + yAt(1).toFixed(1)
].join(' ');
const mid = [];
for (let k = 0; k <= 8; k++) {
const s = k / 8;
mid.push(xAt((c0 + c1) / 2, s).toFixed(1) + ',' + yAt(s).toFixed(1));
}
out += '<g class="lane" data-lane="' + i + '">';
out += '<polyline class="lane-glow" points="' + mid.join(' ') + '"/>';
out += '<polygon class="lane-hit" points="' + poly + '"/>';
out += '</g>';
});
/* bar lines: time, compressed by the perspective */
for (let b = 2; b <= 32; b++) {
const s = sOfBar(b);
const y = yAt(s);
const op = (0.07 + 0.17 * Math.pow(s, 2.2)).toFixed(3);
out += '<line class="bar-line' + (b % 4 === 0 ? ' bar-line--major' : '') + '" stroke-opacity="' + op + '" x1="' + xAt(-1, s).toFixed(1) + '" x2="' + xAt(1, s).toFixed(1) + '" y1="' + y.toFixed(1) + '" y2="' + y.toFixed(1) + '"/>';
}
/* lane edges */
edges.forEach(function (c, i) {
const pts = [];
for (let k = 0; k <= 12; k++) {
const s = k / 12;
pts.push(xAt(c, s).toFixed(1) + ',' + yAt(s).toFixed(1));
}
out += '<polyline class="lane-edge' + (i === 0 || i === edges.length - 1 ? ' lane-edge--outer' : '') + '" points="' + pts.join(' ') + '"/>';
});
/* the gate: where the road meets the city */
out += '<line class="gate-line" x1="' + xAt(-1, 0).toFixed(1) + '" x2="' + xAt(1, 0).toFixed(1) + '" y1="' + yAt(0).toFixed(1) + '" y2="' + yAt(0).toFixed(1) + '"/>';
/* clips */
LANES.forEach(function (lane, i) {
const c = centers[i];
lane.clips.forEach(function (clip, ci) {
const s0 = sOfBar(clip.a);
const s1 = sOfBar(clip.b);
const hpx = yAt(s0) - yAt(s1);
const wpx = xAt(c + cw, s0) - xAt(c - cw, s0);
if (hpx < 2 || wpx < 3) return;
const BL = [xAt(c - cw, s0), yAt(s0)];
const TL = [xAt(c - cw, s1), yAt(s1)];
const TR = [xAt(c + cw, s1), yAt(s1)];
const BR = [xAt(c + cw, s0), yAt(s0)];
const bil = function (u, v) {
const bx = BL[0] + (BR[0] - BL[0]) * u;
const by = BL[1] + (BR[1] - BL[1]) * u;
const tx = TL[0] + (TR[0] - TL[0]) * u;
const ty = TL[1] + (TR[1] - TL[1]) * u;
return [bx + (tx - bx) * v, by + (ty - by) * v];
};
const isNew = clip.id && clip.id === newClipId;
const isNow = state.pos >= clip.a && state.pos < clip.b;
out += '<g class="clip' + (isNew ? ' clip--new' : '') + (isNow ? ' is-now' : '') + (clip.id === state.selClip ? ' is-sel' : '') + '" data-lane="' + i + '" data-a="' + clip.a + '" data-b="' + clip.b + '" data-id="' + clip.id + '">';
const info = clip.core ? esc(clip.name) + ' · ' + clip.core + ' · face ' + String((clip.face || 0) + 1).padStart(2, '0') + (clip.ring ? ' · loop ' + clip.ring + ' bar' : '') : esc(clip.name);
out += '<title>' + esc(info) + '</title>';
const stone = stoneOutline([BL, TL, TR, BR], (i + 3) * 71 + clip.a * 13 + ci * 7);
const cx0 = stone.reduce(function (a, p) { return a + p[0]; }, 0) / stone.length;
const cy0 = stone.reduce(function (a, p) { return a + p[1]; }, 0) / stone.length;
const face = stone.map(function (p) { return [p[0] + (cx0 - p[0]) * 0.1, p[1] + (cy0 - p[1]) * 0.1]; });
out += '<polygon class="clip-body" points="' + ptsStr(stone) + '"/>';
out += '<polygon class="clip-face" points="' + ptsStr(face) + '"/>';
const sp = bil(0.07, 0.78);
out += clipSeal(sp[0], sp[1], clip.seal || 'taken');
if (hpx > 14) {
for (let k = 1; k <= 2; k++) {
const v = k / 3;
const pts = [];
for (let j = 0; j <= 12; j++) {
const u = j / 12;
const p = bil(u * 0.98 + 0.01, clamp(v + 0.03 * Math.sin(u * 8.5 + k * 2 + ci), 0.05, 0.95));
pts.push(p[0].toFixed(1) + ',' + p[1].toFixed(1));
}
out += '<polyline class="clip-strata" points="' + pts.join(' ') + '"/>';
}
}
if (hpx > 16 && wpx > 26) {
const lp = bil(0.04, 0.2);
out += '<text class="clip-name" x="' + lp[0].toFixed(1) + '" y="' + lp[1].toFixed(1) + '">' + clip.name + '</text>';
if (hpx > 54 && wpx > 90) {
const rp = bil(0.96, 0.2);
out += '<text class="clip-era" x="' + rp[0].toFixed(1) + '" y="' + rp[1].toFixed(1) + '">' + clip.a + '–' + clip.b + '</text>';
}
if (clip.ring && hpx > 24) {
const lp2 = bil(0.9, 0.8);
out += '<g class="clip-loop"><circle cx="' + lp2[0].toFixed(1) + '" cy="' + lp2[1].toFixed(1) + '" r="3.4" stroke-dasharray="2 2"/><circle class="clip-loop-dot" cx="' + lp2[0].toFixed(1) + '" cy="' + lp2[1].toFixed(1) + '" r="1"/></g>';
}
}
out += '</g>';
});
});
/* lane names on the near edge, like a desk in front of the road */
LANES.forEach(function (lane, i) {
const x = xAt(centers[i], 1);
out += '<text class="lane-name" data-lane="' + i + '" x="' + x.toFixed(1) + '" y="' + (bottom + 15) + '">' + lane.name + '</text>';
});
/* the near edge: now */
out += '<line class="now-glow" x1="' + xAt(-1, 1).toFixed(1) + '" x2="' + xAt(1, 1).toFixed(1) + '" y1="' + bottom + '" y2="' + bottom + '"/>';
out += '<line class="now-line" x1="' + xAt(-1, 1).toFixed(1) + '" x2="' + xAt(1, 1).toFixed(1) + '" y1="' + bottom + '" y2="' + bottom + '"/>';
/* ruler */
if (W > 560) {
[1, 8, 16, 24, 32].forEach(function (b) {
const s = sOfBar(b);
const y = yAt(s);
const xe = xAt(1, s) + 19;
out += '<line class="ruler-tick" x1="' + xe + '" x2="' + (xe + 4) + '" y1="' + y.toFixed(1) + '" y2="' + y.toFixed(1) + '"/>';
out += '<text class="ruler-text" x="' + (xe + 8) + '" y="' + (y + 2.4).toFixed(1) + '">' + b + '</text>';
});
}
/* playhead */
out += '<g id="ph">';
out += '<line class="playhead-glow"/>';
out += '<line class="playhead-line"/>';
out += '<circle class="playhead-mark" r="2.2"/>';
out += '</g>';
svg.innerHTML = out;
markSelected();
phLine = svg.querySelector('.playhead-line');
phGlow = svg.querySelector('.playhead-glow');
phMark = svg.querySelector('.playhead-mark');
updatePlayhead(true);
}
function updatePlayhead(force) {
if (!NAVE || !phLine) return;
const s = NAVE.sOfBar(state.pos);
const y = NAVE.yAt(s);
const x1 = NAVE.xAt(-1, s);
const x2 = NAVE.xAt(1, s);
[phLine, phGlow].forEach(function (l) {
l.setAttribute('x1', x1.toFixed(1));
l.setAttribute('x2', x2.toFixed(1));
l.setAttribute('y1', y.toFixed(1));
l.setAttribute('y2', y.toFixed(1));
});
phMark.setAttribute('cx', x2.toFixed(1));
phMark.setAttribute('cy', y.toFixed(1));
const key = Math.floor(state.pos * 2);
if (!force && key === lastBarKey) return;
lastBarKey = key;
const bar = Math.floor(state.pos);
const beat = Math.floor((state.pos - bar) * 4) + 1;
const read = $('#corridor-read');
if (read && hotLane < 0 && !state.selClip) read.textContent = 'bar ' + bar + '.' + beat;
$$('.clip', svg).forEach(function (g) {
const a = +g.dataset.a, b = +g.dataset.b;
g.classList.toggle('is-now', state.pos >= a && state.pos < b);
});
const sec = SECTIONS.filter(function (x) { return state.pos >= x.at; }).pop();
if (sec && sec.name !== lastSection) {
lastSection = sec.name;
voidWord.textContent = sec.name;
}
}
function seekFromEvent(e) {
if (!NAVE) return;
const r = svg.getBoundingClientRect();
const y = clamp(e.clientY - r.top, NAVE.horizon, NAVE.bottom);
const s = (y - NAVE.horizon) / (NAVE.bottom - NAVE.horizon);
const t = Math.pow(1 - s, 1 / 1.16);
state.pos = clamp(1 + t * 31, 1, 32.99);
if (E) E.seek(state.pos);
updatePlayhead(true);
updateClock(true);
}
let drag = null;
let redrawQueued = false;
function scheduleRedraw() {
if (redrawQueued) return;
redrawQueued = true;
requestAnimationFrame(function () {
redrawQueued = false;
buildNave();
});
}
svg.addEventListener('pointerdown', function (e) {
if (e.target.closest('.ruler-text, .ruler-tick')) return;
const g = e.target.closest ? e.target.closest('.clip') : null;
if (g && g.dataset.id) {
const id = +g.dataset.id;
const f = findClip(id);
if (!f) return;
selectClip(id);
const r = svg.getBoundingClientRect();
drag = {
id: id, y0: e.clientY, bar0: barFromY(e.clientY - r.top),
a0: f.clip.a, b0: f.clip.b, moved: false, undid: false
};
try { svg.setPointerCapture(e.pointerId); } catch (err) {}
e.preventDefault();
e.stopPropagation();
return;
}
state.selClip = null;
markSelected();
seekFromEvent(e);
});
svg.addEventListener('pointermove', function (e) {
if (!drag) return;
const r = svg.getBoundingClientRect();
if (!drag.moved && Math.abs(e.clientY - drag.y0) > 3) {
drag.moved = true;
pushUndo();
drag.undid = true;
}
if (!drag.moved) return;
const bar = barFromY(e.clientY - r.top);
const delta = Math.round((bar - drag.bar0) * 4) / 4;
const f = findClip(drag.id);
if (!f) return;
const len = drag.b0 - drag.a0;
const a = clamp(drag.a0 + delta, 1, 32 - len);
f.clip.a = +a.toFixed(2);
f.clip.b = +(a + len).toFixed(2);
scheduleRedraw();
});
function endDrag() {
if (!drag) return;
const moved = drag.moved;
const id = drag.id;
drag = null;
if (moved) {
const f = findClip(id);
commitSong();
scheduleSave();
updatePlayhead(true);
if (E && f) E.addLog('stones.move', f.clip.name + ' → bar ' + f.clip.a.toFixed(2), f.clip.seal || '', 'hand');
}
}
svg.addEventListener('pointerup', endDrag);
svg.addEventListener('pointercancel', endDrag);
svg.addEventListener('pointerleave', function () { if (drag) endDrag(); });
function setHot(i) {
if (i === hotLane) return;
hotLane = i;
$$('.lane', svg).forEach(function (el) { el.classList.toggle('is-hot', +el.dataset.lane === i); });
$$('.lane-name', svg).forEach(function (el) { el.classList.toggle('is-hot', +el.dataset.lane === i); });
$$('.ch', fadersEl).forEach(function (el) { el.classList.toggle('is-hot', +el.dataset.lane === i); });
const read = $('#corridor-read');
if (!read) return;
if (i >= 0) read.textContent = LANES[i].name;
else {
const bar = Math.floor(state.pos);
const beat = Math.floor((state.pos - bar) * 4) + 1;
read.textContent = 'bar ' + bar + '.' + beat;
}
}
svg.addEventListener('pointermove', function (e) {
const el = e.target.closest ? e.target.closest('.clip, .lane') : null;
setHot(el ? +el.dataset.lane : -1);
});
svg.addEventListener('pointerleave', function () { setHot(-1); });
/* ————— faders ————— */
const channelEls = [];
function dbText(g) {
if (g <= 0.03) return '−∞';
return (20 * Math.log10(g)).toFixed(1).replace('-', '−');
}
function paintChannel(i) {
const ch = channelEls[i];
if (!ch) return;
const g = LANES[i].gain;
const p = (g * 100).toFixed(1);
ch.fill.style.height = p + '%';
if (!state.playing && !(A.level > 0.015)) ch.bead.style.bottom = 'calc(' + p + '% - 4.5px)';
ch.db.textContent = dbText(g);
ch.el.setAttribute('aria-valuenow', Math.round(g * 100));
if (E) E.setVeinGain(i, g);
}
function buildFaders() {
fadersEl.innerHTML = '';
channelEls.length = 0;
LANES.forEach(function (lane, i) {
const el = document.createElement('div');
el.className = 'ch';
el.dataset.lane = i;
el.tabIndex = 0;
el.setAttribute('role', 'slider');
el.setAttribute('aria-label', 'Level of vein ' + lane.name);
el.setAttribute('aria-valuemin', '0');
el.setAttribute('aria-valuemax', '100');
el.innerHTML = '<span class="ch-db"></span><div class="ch-track"><i class="ch-fill"></i><i class="ch-bead"></i></div><span class="ch-name">' + lane.name + '</span>';
const obj = {
el: el,
fill: el.querySelector('.ch-fill'),
bead: el.querySelector('.ch-bead'),
db: el.querySelector('.ch-db')
};
channelEls.push(obj);
let dragging = false;
const trackEl = el.querySelector('.ch-track');
function setFromY(clientY) {
const r = trackEl.getBoundingClientRect();
LANES[i].gain = clamp(1 - (clientY - r.top) / r.height, 0.02, 1);
paintChannel(i);
}
el.addEventListener('pointerdown', function (e) {
dragging = true;
el.setPointerCapture(e.pointerId);
setFromY(e.clientY);
e.preventDefault();
});
el.addEventListener('pointermove', function (e) { if (dragging) setFromY(e.clientY); });
el.addEventListener('pointerup', function () { dragging = false; });
el.addEventListener('pointercancel', function () { dragging = false; });
el.addEventListener('pointerenter', function () { setHot(i); });
el.addEventListener('pointerleave', function () { setHot(-1); });
el.addEventListener('keydown', function (e) {
const step = e.shiftKey ? 0.1 : 0.02;
if (e.key === 'ArrowUp' || e.key === 'ArrowRight') { LANES[i].gain = clamp(LANES[i].gain + step, 0.02, 1); paintChannel(i); e.preventDefault(); }
else if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') { LANES[i].gain = clamp(LANES[i].gain - step, 0.02, 1); paintChannel(i); e.preventDefault(); }
});
fadersEl.appendChild(el);
paintChannel(i);
});
}
/* ————— crystal ————— */
const PC_ROW = { 0: 0, 2: 1, 3: 2, 5: 3, 7: 4, 8: 5, 10: 6, 11: 7 };
let latticeWindow = -1;
let latPlayhead = null;
function latticeWindowStart() {
return clamp(Math.floor(state.pos) - 1, 1, 29);
}
function buildLattice() {
if (!latticeEl) return;
const W = latticeEl.clientWidth;
const H = latticeEl.clientHeight;
if (!W || !H) return;
const padL = 16, padR = 3, padT = 5, padB = 4;
const rows = 8, cols = 16;
const gw = (W - padL - padR) / cols;
const gh = (H - padT - padB) / rows;
const start = latticeWindowStart();
latticeWindow = start;
const barsEl = $('#crystal-bars');
if (barsEl) barsEl.textContent = 'bars ' + start + '–' + (start + 3);
let out = '<svg viewBox="0 0 ' + W + ' ' + H + '" preserveAspectRatio="none" aria-hidden="true">';
for (let r = 0; r <= rows; r++) {
const y = (padT + r * gh).toFixed(1);
out += '<line class="lat-grid" x1="' + padL + '" x2="' + (W - padR) + '" y1="' + y + '" y2="' + y + '"/>';
}
for (let c = 0; c <= cols; c++) {
const x = (padL + c * gw).toFixed(1);
const bar = c % 4 === 0;
out += '<line class="' + (bar ? 'lat-grid lat-grid--bar' : 'lat-grid') + '" x1="' + x + '" x2="' + x + '" y1="' + padT + '" y2="' + (H - padB) + '"/>';
}
NOTE_NAMES.forEach(function (n, r) {
const y = (padT + r * gh + gh * 0.73).toFixed(1);
out += '<text class="lat-key" x="0" y="' + y + '">' + n + '</text>';
});
if (E) {
const evs = E.getEvents() || [];
const seen = {};
evs.forEach(function (ev) {
const rel = ev.bar - start;
if (rel < 0 || rel >= 4) return;
const col = Math.max(0, Math.min(cols - 1, Math.floor(rel * 4)));
const pc = ((ev.pitch % 12) + 12) % 12;
let row = PC_ROW[pc];
if (row == null) row = Math.abs(pc) % rows;
const key = col + ':' + row;
if (seen[key]) return;
seen[key] = 1;
const x = padL + col * gw + 0.6;
const y = padT + row * gh + 2;
const w = Math.max(2, gw - 1.6);
const h = Math.max(2, gh - 4);
const tonic = row === 0 || row === rows - 1;
out += '<rect class="lat-note' + (tonic ? ' lat-note--lone' : '') + '" x="' + x.toFixed(1) + '" y="' + y.toFixed(1) + '" width="' + w.toFixed(1) + '" height="' + h.toFixed(1) + '"/>';
});
}
const ph = clamp((state.pos - start) / 4, 0, 1) * (W - padL - padR) + padL;
out += '<line id="lat-ph" class="lat-playhead" x1="' + ph.toFixed(1) + '" x2="' + ph.toFixed(1) + '" y1="' + padT + '" y2="' + (H - padB) + '"/>';
out += '</svg>';
latticeEl.innerHTML = out;
latPlayhead = latticeEl.querySelector('#lat-ph');
}
function updateLatticePlayhead() {
if (!latPlayhead || !E) return;
const W = latticeEl.clientWidth;
const padL = 16, padR = 3;
const ph = clamp((state.pos - latticeWindow) / 4, 0, 1) * (W - padL - padR) + padL;
latPlayhead.setAttribute('x1', ph.toFixed(1));
latPlayhead.setAttribute('x2', ph.toFixed(1));
}
/* ————— knobs ————— */
function polar(cx, cy, r, deg) {
const a = deg * Math.PI / 180;
return [cx + r * Math.cos(a), cy + r * Math.sin(a)];
}
function arcPath(cx, cy, r, a0, a1) {
const p0 = polar(cx, cy, r, a0);
const p1 = polar(cx, cy, r, a1);
const large = Math.abs(a1 - a0) > 180 ? 1 : 0;
return 'M' + p0[0].toFixed(2) + ' ' + p0[1].toFixed(2) + ' A' + r + ' ' + r + ' 0 ' + large + ' 1 ' + p1[0].toFixed(2) + ' ' + p1[1].toFixed(2);
}
function paintKnob(el) {
const v = clamp(+el.dataset.value, 0, 1);
const a0 = 135, a1 = 405;
const a = a0 + (a1 - a0) * v;
el.querySelector('.k-arc').setAttribute('d', arcPath(17, 17, 13, a0, a));
const t0 = polar(17, 17, 10, a), t1 = polar(17, 17, 15, a);
el.querySelector('.k-tick').setAttribute('d', 'M' + t0[0].toFixed(2) + ' ' + t0[1].toFixed(2) + ' L' + t1[0].toFixed(2) + ' ' + t1[1].toFixed(2));
el.setAttribute('aria-valuenow', Math.round(v * 100));
if (E) E.setMacro(el.dataset.knob, v);
}
function initKnobs() {
$$('.knob').forEach(function (el) {
el.tabIndex = 0;
el.setAttribute('role', 'slider');
el.setAttribute('aria-label', el.dataset.knob);
el.setAttribute('aria-valuemin', '0');
el.setAttribute('aria-valuemax', '100');
paintKnob(el);
let dragging = false, y0 = 0, v0 = 0;
el.addEventListener('pointerdown', function (e) {
dragging = true; y0 = e.clientY; v0 = +el.dataset.value;
el.setPointerCapture(e.pointerId);
e.preventDefault();
});
el.addEventListener('pointermove', function (e) {
if (!dragging) return;
el.dataset.value = clamp(v0 + (y0 - e.clientY) / 110, 0, 1);
paintKnob(el);
});
el.addEventListener('pointerup', function () { dragging = false; });
el.addEventListener('keydown', function (e) {
const step = e.shiftKey ? 0.1 : 0.03;
if (e.key === 'ArrowUp' || e.key === 'ArrowRight') { el.dataset.value = clamp(+el.dataset.value + step, 0, 1); paintKnob(el); e.preventDefault(); }
else if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') { el.dataset.value = clamp(+el.dataset.value - step, 0, 1); paintKnob(el); e.preventDefault(); }
});
});
}
/* ————— halo & breath ————— */
function sizeHalo() {
const wrap = $('.void-wrap');
if (!wrap) return;
const r = wrap.getBoundingClientRect();
const dpr = Math.min(2, window.devicePixelRatio || 1);
haloW = Math.round(r.width + 176);
haloH = Math.round(r.height + 176);
halo.width = Math.round(haloW * dpr);
halo.height = Math.round(haloH * dpr);
hctx.setTransform(dpr, 0, 0, dpr, 0, 0);
voidR = r.width / 2;
eyeCX = r.left + r.width / 2;
eyeCY = r.top + r.height / 2;
}
function sizeScope() {
if (!scope) return;
const dpr = Math.min(2, window.devicePixelRatio || 1);
const r = scope.getBoundingClientRect();
scopeW = Math.round(r.width);
scopeH = Math.round(r.height);
scope.width = Math.max(2, Math.round(scopeW * dpr));
scope.height = Math.max(2, Math.round(scopeH * dpr));
sctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function drawScope() {
if (!scopeW) return;
sctx.clearRect(0, 0, scopeW, scopeH);
const mid = scopeH / 2;
sctx.strokeStyle = 'rgba(155,133,121,.2)';
sctx.lineWidth = 0.7;
sctx.beginPath();
sctx.moveTo(0, mid);
sctx.lineTo(scopeW, mid);
sctx.stroke();
const wave = A.wave;
if (!wave) return;
sctx.beginPath();
for (let i = 0; i < wave.length; i++) {
const x = (i / (wave.length - 1)) * scopeW;
const y = mid - wave[i] * mid * 0.92;
if (i === 0) sctx.moveTo(x, y); else sctx.lineTo(x, y);
}
sctx.strokeStyle = 'rgba(232,64,42,.9)';
sctx.lineWidth = 1;
sctx.stroke();
}
function sizeBreath() {
breathW = Math.max(2, Math.round(window.innerWidth * BS));
breathH = Math.max(2, Math.round(window.innerHeight * BS));
breath.width = breathW;
breath.height = breathH;
}
function drawHalo(now) {
if (!haloW) return;
hctx.clearRect(0, 0, haloW, haloH);
const cx = haloW / 2, cy = haloH / 2, R = voidR;
const beat = (state.pos - 1) * 4;
const rings = reduceMotion ? 1 : 3;
for (let k = 0; k < rings; k++) {
const ph = frac(beat * 0.25 + k / rings);
const rr = R * (1.08 + ph * 1.5);
hctx.beginPath();
for (let a = 0; a <= 80; a++) {
const ang = (a / 80) * TAU;
const wob = 1 + 0.02 * Math.sin(ang * 3 + now * 0.11 + k) + 0.014 * Math.sin(ang * 7 - now * 0.07);
const x = cx + Math.cos(ang) * rr * wob;
const y = cy + Math.sin(ang) * rr * wob;
if (a === 0) hctx.moveTo(x, y); else hctx.lineTo(x, y);
}
hctx.closePath();
hctx.strokeStyle = 'rgba(232,64,42,' + (0.17 * (1 - ph)).toFixed(3) + ')';
hctx.lineWidth = 0.8;
hctx.stroke();
}
/* the spectrum, as needles */
const N = A.spec.length;
const rot = reduceMotion ? 0 : now * 0.01;
const base = A.level;
for (let i = 0; i < N; i++) {
const ang = (i / N) * TAU - Math.PI / 2 + rot;
const v = A.spec[i];
const len = 2 + (0.12 + v * 1.1) * (0.4 + base) * R * 0.62;
const r0 = R * (1.05 + 0.015 * Math.sin(ang * 5 + now * 0.3));
const c = Math.cos(ang), s = Math.sin(ang);
hctx.beginPath();
hctx.moveTo(cx + c * r0, cy + s * r0);
hctx.lineTo(cx + c * (r0 + len), cy + s * (r0 + len));
hctx.strokeStyle = 'rgba(232,64,42,' + (0.1 + 0.4 * Math.min(1, len / (R * 0.55))).toFixed(3) + ')';
hctx.lineWidth = 0.75;
hctx.stroke();
}
}
function drawBreath(now) {
if (!breathW) return;
bctx.clearRect(0, 0, breathW, breathH);
const lv = A.level;
if (lv < 0.01 && A.ripples.length === 0) return;
const cx = eyeCX * BS, cy = eyeCY * BS, R = voidR * BS;
bctx.globalCompositeOperation = 'lighter';
if (lv > 0.01) {
const g = bctx.createRadialGradient(cx, cy, R * 0.8, cx, cy, R * (2.4 + 1.8 * lv));
g.addColorStop(0, 'rgba(232,64,42,' + (0.06 + 0.18 * lv).toFixed(3) + ')');
g.addColorStop(0.42, 'rgba(232,64,42,' + (0.02 + 0.07 * lv).toFixed(3) + ')');
g.addColorStop(1, 'rgba(232,64,42,0)');
bctx.fillStyle = g;
bctx.fillRect(0, 0, breathW, breathH);
}
A.ripples.forEach(function (rip) {
const p = (now - rip.t) / 1.7;
if (p > 1) return;
const rr = R * (1.12 + p * 3.4);
bctx.beginPath();
for (let a = 0; a <= 90; a++) {
const ang = (a / 90) * TAU;
const wob = 1 + 0.012 * Math.sin(ang * 5 + rip.seed) + 0.008 * Math.sin(ang * 11 - rip.seed);
const x = cx + Math.cos(ang) * rr * wob;
const y = cy + Math.sin(ang) * rr * wob;
if (a === 0) bctx.moveTo(x, y); else bctx.lineTo(x, y);
}
bctx.closePath();
bctx.strokeStyle = 'rgba(232,64,42,' + (0.24 * (1 - p) * rip.amp).toFixed(3) + ')';
bctx.lineWidth = 1.1;
bctx.stroke();
});
A.ripples = A.ripples.filter(function (r) { return now - r.t < 1.7; });
bctx.globalCompositeOperation = 'source-over';
}
/* ————— analysis: the instrument listens ————— */
const E = window.ZHILA_ENGINE;
const A = {
level: 0, rms: 0,
bands: [0, 0, 0, 0, 0],
spec: new Float32Array(72),
wave: new Float32Array(120),
ripples: [], lastRipple: 0,
sr: E ? E.sr : 44100
};
function analyse(now) {
if (!E) return;
const d = E.read();
const freq = d.freq, td = d.time;
const binHz = E.sr / 2048;
const ranges = [[26, 140], [140, 400], [400, 1500], [1500, 4500], [4500, 12000]];
const gains = [0.46, 0.72, 1.0, 1.45, 2.0];
for (let i = 0; i < 5; i++) {
const a = Math.floor(ranges[i][0] / binHz);
const b = Math.min(freq.length - 1, Math.ceil(ranges[i][1] / binHz));
let sum = 0, count = 0;
for (let k = a; k <= b; k++) { sum += freq[k]; count++; }
let v = count ? (sum / count) / 255 : 0;
v = Math.pow(v, 0.7) * gains[i];
const prev = A.bands[i];
A.bands[i] = prev + (clamp(v, 0, 1) - prev) * (v > prev ? 0.55 : 0.12);
}
let sum = 0;
for (let i = 0; i < td.length; i++) {
const v = (td[i] - 128) / 128;
sum += v * v;
}
const rms = Math.sqrt(sum / td.length);
A.rms = A.rms + (rms - A.rms) * (rms > A.rms ? 0.5 : 0.09);
const target = clamp(A.rms * 2.8, 0, 1);
A.level = A.level + (target - A.level) * (target > A.level ? 0.6 : 0.12);
if (A.bands[0] > 0.42 && now - A.lastRipple > 0.32) {
A.ripples.push({ t: now, amp: clamp(A.bands[0] * 1.3, 0.2, 1), seed: Math.random() * 6 });
A.lastRipple = now;
}
const step = td.length / A.wave.length;
for (let i = 0; i < A.wave.length; i++) {
A.wave[i] = (td[Math.floor(i * step)] - 128) / 128;
}
for (let j = 0; j < A.spec.length; j++) {
const f = 32 * Math.pow(14000 / 32, j / (A.spec.length - 1));
const k = Math.min(freq.length - 1, Math.round(f / binHz));
let v = freq[k] / 255;
v = Math.pow(v, 0.75) * (0.7 + 0.85 * (j / A.spec.length));
A.spec[j] += (clamp(v, 0, 1) - A.spec[j]) * 0.38;
}
}
/* ————— extraction sources ————— */
function markSource(src) {
$$('.src').forEach(function (b) {
const on = b.dataset.src === src;
b.classList.toggle('is-on', on);
b.setAttribute('aria-pressed', String(on));
});
}
function revealCore(core) {
if (!core) return;
const q = window.ZHILA_QUARRY;
if (q) {
q.setView('quarry');
if (q.focusCore) q.focusCore(core.id);
}
}
function extractCore(src) {
const core = E.newCore({ seal: 'taken', name: src === 'room' ? 'room, live' : undefined });
E.addLog('cores.extract', core.name, 'taken', 'hand');
markSource(src);
revealCore(core);
}
function recordRoom(btn) {
if (!E || btn.dataset.busy) return;
btn.dataset.busy = '1';
const old = btn.textContent;
btn.textContent = 'room 6';
btn.classList.add('is-rec');
let left = 6;
const iv = setInterval(function () {
left--;
if (left > 0) btn.textContent = 'room ' + left;
}, 1000);
return E.recordRoom(6).then(function (core) {
clearInterval(iv);
btn.textContent = old;
delete btn.dataset.busy;
btn.classList.remove('is-rec');
markSource('room');
E.addLog('cores.extract', core.name, 'taken', 'hand');
revealCore(core);
}).catch(function () {
clearInterval(iv);
btn.textContent = 'no room';
delete btn.dataset.busy;
btn.classList.remove('is-rec');
setTimeout(function () { btn.textContent = old; }, 1400);
});
}
$$('.src').forEach(function (b) {
b.addEventListener('click', function () {
const src = b.dataset.src;
if (src === 'file') { fileIn.click(); return; }
if (src === 'room') { recordRoom(b); return; }
extractCore(src);
});
});
fileIn.addEventListener('change', function () {
const f = fileIn.files && fileIn.files[0];
if (!f) return;
const btn = $('.src[data-src="file"]');
btn.textContent = 'file…';
E.importFile(f).then(function (core) {
btn.textContent = 'file';
markSource('file');
E.addLog('cores.extract', core.name, 'taken', 'hand');
revealCore(core);
}).catch(function () {
btn.textContent = 'file';
});
fileIn.value = '';
});
/* ————— song → engine ————— */
function resolveClips() {
LANES.forEach(function (lane) {
lane.clips.forEach(function (c) {
if (!c.id) c.id = ++clipSeq;
if (c.id > clipSeq) clipSeq = c.id;
if (c.core && c.faceRank != null && c.face == null) {
c.face = E.rankFace(c.core, c.faceRank);
}
});
});
}
function findClip(id) {
for (let li = 0; li < LANES.length; li++) {
const clips = LANES[li].clips;
for (let ci = 0; ci < clips.length; ci++) {
if (clips[ci].id === id) return { lane: LANES[li], li: li, i: ci, clip: clips[ci] };
}
}
return null;
}
function markSelected() {
$$('.clip', svg).forEach(function (g) {
g.classList.toggle('is-sel', +g.dataset.id === state.selClip);
});
}
function selectClip(id) {
state.selClip = id;
markSelected();
const f = findClip(id);
const read = $('#corridor-read');
if (read && f) {
const label = f.clip.core ? f.clip.core + ' · f' + String((f.clip.face || 0) + 1).padStart(2, '0') : '';
read.textContent = f.clip.name + (f.clip.ring ? ' ↻' + f.clip.ring : '') + (label ? ' · ' + label : '');
}
}
function deleteSelected() {
const f = findClip(state.selClip);
if (!f) return;
pushUndo();
const name = f.clip.name;
f.lane.clips.splice(f.i, 1);
state.selClip = null;
buildNave();
commitSong();
scheduleSave();
if (E) E.addLog('stones.bury', name, f.clip.seal || '', 'hand');
}
function duplicateSelected() {
const f = findClip(state.selClip);
if (!f) return;
pushUndo();
const c = Object.assign({}, f.clip);
const len = c.b - c.a;
c.a = Math.min(32 - len, c.b);
c.b = c.a + len;
c.id = ++clipSeq;
f.lane.clips.push(c);
state.selClip = c.id;
buildNave();
commitSong();
scheduleSave();
if (E) E.addLog('stones.duplicate', c.name + ' → bar ' + c.a, c.seal || '', 'hand');
}
function barFromY(y) {
if (!NAVE) return 1;
const s = clamp((y - NAVE.horizon) / (NAVE.bottom - NAVE.horizon), 0, 1);
return 1 + Math.pow(1 - s, 1 / 1.16) * 31;
}
function commitSong() {
if (!E) return;
const stones = [];
LANES.forEach(function (lane, i) {
lane.clips.forEach(function (c) {
if (!c.core || c.face == null) return;
stones.push({
a: c.a, b: c.b, coreId: c.core, face: c.face,
ring: c.ring || 0, pitch: c.pitch || 0,
gain: c.gain == null ? 0.8 : c.gain, vein: i
});
});
});
E.setSong(stones);
if (typeof buildLattice === 'function') buildLattice();
}
/* ————— history & project ————— */
let undoStack = [];
let redoStack = [];
let saveTimer = null;
function snapshot() {
return JSON.stringify({
bpm: state.bpm,
lanes: LANES.map(function (l) { return l.clips.map(function (c) { return Object.assign({}, c); }); })
});
}
function pushUndo() {
undoStack.push(snapshot());
if (undoStack.length > 40) undoStack.shift();
redoStack.length = 0;
}
function applySnapshot(json) {
let d;
try { d = JSON.parse(json); } catch (e) { return; }
if (!d || !Array.isArray(d.lanes)) return;
LANES.forEach(function (lane, i) { lane.clips = (d.lanes[i] || []).map(function (c) { return Object.assign({}, c); }); });
state.bpm = clamp(d.bpm || 128, 40, 220);
const bpmEl = $('#bpm');
if (bpmEl) bpmEl.textContent = state.bpm.toFixed(2);
if (E) E.setBpm(state.bpm);
buildNave();
commitSong();
scheduleSave();
updateClock(true);
}
function undo() {
if (!undoStack.length) return;
redoStack.push(snapshot());
applySnapshot(undoStack.pop());
}
function redo() {
if (!redoStack.length) return;
undoStack.push(snapshot());
applySnapshot(redoStack.pop());
}
function scheduleSave() {
clearTimeout(saveTimer);
saveTimer = setTimeout(saveProject, 450);
}
function saveProject() {
try { localStorage.setItem('zhila.project.v3', snapshot()); } catch (e) {}
}
function loadProject() {
let raw = null;
try { raw = localStorage.getItem('zhila.project.v3'); } catch (e) {}
if (!raw) return false;
try {
const d = JSON.parse(raw);
if (!d || !Array.isArray(d.lanes)) return false;
LANES.forEach(function (lane, i) {
lane.clips = (d.lanes[i] || []).map(function (c) { return Object.assign({}, c); });
});
state.bpm = clamp(d.bpm || 128, 40, 220);
return true;
} catch (e) { return false; }
}
/* ————— transport ————— */
const playBtn = $('#play');
const timeEl = $('#time');
const barsEl = $('#bars');
const masterDbEl = $('#master-db');
const meterFill = $('#meter-fill');
const meterPeak = $('#meter-peak');
function setPlaying(v) {
if (!E) return;
if (v) E.play(); else E.pause();
state.playing = E.isPlaying();
document.body.classList.toggle('is-playing', state.playing);
playBtn.setAttribute('aria-pressed', String(state.playing));
playBtn.setAttribute('aria-label', state.playing ? 'Pause' : 'Play');
if (!state.playing) {
state.pos = E.getPos();
channelEls.forEach(function (ch, i) { paintChannel(i); });
}
}
playBtn.addEventListener('click', function () { setPlaying(!state.playing); });
$('#stop').addEventListener('click', function () {
if (!E) return;
E.stop();
setPlaying(false);
state.pos = 1;
updatePlayhead(true);
updateClock(true);
});
$('#rec').addEventListener('click', function () {
if (!E || this.dataset.busy) return;
const self = this;
self.dataset.busy = '1';
self.setAttribute('aria-pressed', 'true');
voidBtn.classList.add('is-pulsed');
recordRoom($('.src[data-src="room"]')).then(function () {
self.setAttribute('aria-pressed', 'false');
delete self.dataset.busy;
voidBtn.classList.remove('is-pulsed');
});
});
$('#snd').addEventListener('click', function () {
state.sound = !state.sound;
this.setAttribute('aria-pressed', String(state.sound));
this.setAttribute('aria-label', state.sound ? 'Audio on' : 'Audio off');
if (E) E.setMuted(!state.sound);
});
function updateClock(force) {
const barSec = 60 / state.bpm * 4;
const sec = Math.max(0, (state.pos - 1) * barSec);
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
const cs = Math.floor((sec % 1) * 100);
const html = String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0') + '.<span class="ms">' + String(cs).padStart(2, '0') + '</span>';
if (force || html !== lastClock) {
timeEl.innerHTML = html;
lastClock = html;
}
const bar = Math.floor(state.pos);
const beat = Math.floor((state.pos - bar) * 4) + 1;
const b = bar + '.' + beat;
if (barsEl.textContent !== b) barsEl.textContent = b;
}
/* ————— tempo drag ————— */
(function () {
const el = $('#bpm');
let dragging = false, y0 = 0, v0 = 0;
el.addEventListener('pointerdown', function (e) {
dragging = true; y0 = e.clientY; v0 = state.bpm;
el.setPointerCapture(e.pointerId);
e.preventDefault();
});
el.addEventListener('pointermove', function (e) {
if (!dragging) return;
state.bpm = clamp(Math.round((v0 + (y0 - e.clientY) * 0.25) * 100) / 100, 40, 220);
el.textContent = state.bpm.toFixed(2);
if (E) E.setBpm(state.bpm);
updateClock(true);
});
el.addEventListener('pointerup', function () { dragging = false; });
el.addEventListener('dblclick', function () { state.bpm = 128; el.textContent = '128.00'; if (E) E.setBpm(128); updateClock(true); });
})();
/* ————— levels ————— */
function updateLevels(now) {
const lv = A.level;
meterFill.style.transform = 'scaleX(' + lv.toFixed(3) + ')';
peak = Math.max(lv, peak - 0.006);
meterPeak.style.left = (peak * 100).toFixed(2) + '%';
const db = 20 * Math.log10(Math.max(A.rms, 1e-5));
masterDbEl.textContent = (A.rms > 0.0008 ? (db > -0.05 ? '0.0' : db.toFixed(1)) : '−∞').replace('-', '−') + ' dB';
channelEls.forEach(function (ch, i) {
const g = LANES[i].gain;
const live = (A.level > 0.015 || state.playing) ? clamp(A.bands[i], 0.004, 1) : g;
ch.bead.style.bottom = 'calc(' + (live * 100).toFixed(1) + '% - 4.5px)';
});
if (Math.abs(lv - lastLv) > 0.006) {
lastLv = lv;
document.documentElement.style.setProperty('--lv', lv.toFixed(3));
}
}
/* ————— loop ————— */
function frame(now) {
if (E && E.isPlaying()) state.pos = E.getPos();
analyse(now / 1000);
updatePlayhead(false);
updateClock(false);
if (E && E.isPlaying() && latticeEl.clientWidth > 0 && latticeWindow !== latticeWindowStart()) buildLattice();
updateLatticePlayhead();
updateLevels(now / 1000);
drawHalo(now / 1000);
drawBreath(now / 1000);
drawScope();
requestAnimationFrame(frame);
}
/* ————— build ————— */
function buildAll() {
resolveClips();
state.bpm = E ? E.getBpm() : state.bpm;
if (loadProject()) resolveClips();
commitSong();
if (E) E.setBpm(state.bpm);
const bpmEl = $('#bpm');
if (bpmEl) bpmEl.textContent = state.bpm.toFixed(2);
buildNave();
buildLattice();
buildFaders();
initKnobs();
sizeHalo();
sizeBreath();
sizeScope();
if (window.ZHILA_TERRAIN) {
window.ZHILA_TERRAIN.measure();
window.ZHILA_TERRAIN.render();
window.ZHILA_TERRAIN.drawSpires();
}
logEl.scrollTop = logEl.scrollHeight;
updateClock(true);
updatePlayhead(true);
}
let fontsSettled = false;
function relayout() {
buildNave();
buildLattice();
sizeHalo();
sizeBreath();
sizeScope();
if (window.ZHILA_TERRAIN) {
window.ZHILA_TERRAIN.measure();
window.ZHILA_TERRAIN.render();
window.ZHILA_TERRAIN.drawSpires();
}
}
let resizeTimer = null;
window.addEventListener('resize', function () {
window.clearTimeout(resizeTimer);
resizeTimer = window.setTimeout(function () {
buildNave();
buildLattice();
sizeHalo();
sizeBreath();
sizeScope();
if (window.ZHILA_TERRAIN) {
window.ZHILA_TERRAIN.measure();
window.ZHILA_TERRAIN.render();
window.ZHILA_TERRAIN.drawSpires();
}
}, 200);
});
document.addEventListener('keydown', function (e) {
const tag = (e.target && e.target.tagName) || '';
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
if (e.code === 'Space') {
e.preventDefault();
setPlaying(!state.playing);
}
const mod = e.metaKey || e.ctrlKey;
if (mod && e.key.toLowerCase() === 'z') {
e.preventDefault();
if (e.shiftKey) redo(); else undo();
}
if (mod && e.key.toLowerCase() === 'd') {
e.preventDefault();
duplicateSelected();
}
if ((e.key === 'Backspace' || e.key === 'Delete') && state.selClip) {
e.preventDefault();
deleteSelected();
}
});
const layerBtn = $('.tp--end .ghost');
if (layerBtn) layerBtn.addEventListener('click', function () { askAgent('one more layer, four bars'); });
const outBtn = $('.ghost--ember');
if (outBtn) outBtn.addEventListener('click', function () {
if (outBtn.dataset.busy || !E) return;
outBtn.dataset.busy = '1';
outBtn.textContent = 'rendering';
E.download().then(function () {
outBtn.textContent = 'done';
window.setTimeout(function () {
outBtn.textContent = 'render';
delete outBtn.dataset.busy;
}, 1800);
}).catch(function () {
outBtn.textContent = 'render';
delete outBtn.dataset.busy;
});
});
/* ————— command layer (hand and agents use the same verbs) ————— */
function stonesSummary() {
const out = [];
LANES.forEach(function (lane, i) {
lane.clips.forEach(function (c) {
out.push({
id: c.id, vein: lane.name, name: c.name,
bars: [+(+c.a).toFixed(2), +(+c.b).toFixed(2)],
ring: c.ring || 0, core: c.core, face: c.face,
pitch: c.pitch || 0, gain: c.gain == null ? 0.8 : c.gain,
seal: c.seal
});
});
});
return out;
}
function addStoneAt(opts) {
opts = opts || {};
if (!E) return { error: 'no engine' };
const coreId = opts.core && E.byId(opts.core) ? opts.core : (E.cores[0] && E.cores[0].id);
if (!coreId) return { error: 'no cores' };
let vi = typeof opts.vein === 'number' ? opts.vein : LANES.findIndex(function (l) { return l.name === opts.vein; });
if (vi < 0) vi = 0;
const lane = LANES[clamp(vi, 0, 4)];
const face = opts.face != null ? clamp(Math.round(opts.face), 0, 15) : E.rankFace(coreId, 0);
const a = clamp(+opts.start || 1, 1, 32);
const len = clamp(+opts.length || 4, 0.25, 32);
const b = Math.min(32, a + len);
const ring = clamp(+opts.ring || 0, 0, 8);
const pitch = clamp(Math.round(+opts.pitch || 0), -12, 12);
const gain = clamp(opts.gain == null ? 0.85 : +opts.gain, 0.1, 1.2);
pushUndo();
const clip = {
a: +a.toFixed(2), b: +b.toFixed(2),
name: opts.name || 'face ' + String(face + 1).padStart(2, '0'),
seal: opts.seal || 'taken', core: coreId, face: face,
ring: ring, pitch: pitch, gain: gain, id: ++clipSeq
};
lane.clips.push(clip);
lane.clips.sort(function (x, y) { return x.a - y.a; });
buildNave(clip.id);
commitSong();
scheduleSave();
E.addLog('stones.place', clip.name + ' → ' + lane.name + ', bar ' + clip.a, clip.seal, opts.actor || 'api');
return {
id: clip.id, vein: lane.name, name: clip.name, core: coreId, face: face,
bars: [clip.a, clip.b], ring: ring, pitch: pitch, gain: gain
};
}
function moveStoneAt(id, start) {
const f = findClip(id);
if (!f) return { error: 'no such stone' };
pushUndo();
const len = f.clip.b - f.clip.a;
const a = clamp(+start || 1, 1, Math.max(1, 32 - len));
f.clip.a = +a.toFixed(2);
f.clip.b = +(a + len).toFixed(2);
buildNave();
commitSong();
scheduleSave();
if (E) E.addLog('stones.move', f.clip.name + ' → bar ' + f.clip.a, f.clip.seal || '', 'api');
return { id: id, bars: [f.clip.a, f.clip.b] };
}
function deleteStoneAt(id) {
const f = findClip(id);
if (!f) return { error: 'no such stone' };
pushUndo();
const name = f.clip.name;
f.lane.clips.splice(f.i, 1);
if (state.selClip === id) state.selClip = null;
buildNave();
commitSong();
scheduleSave();
if (E) E.addLog('stones.bury', name, f.clip.seal || '', 'api');
return { id: id, deleted: true };
}
function setTempoValue(bpm) {
state.bpm = clamp(+bpm || 128, 40, 220);
if (E) E.setBpm(state.bpm);
const el = $('#bpm');
if (el) el.textContent = state.bpm.toFixed(2);
updateClock(true);
return state.bpm;
}
function transportAt(action, bar) {
if (!E) return { error: 'no engine' };
if (action === 'play') setPlaying(true);
else if (action === 'pause') setPlaying(false);
else if (action === 'stop') {
E.stop();
setPlaying(false);
state.pos = 1;
updatePlayhead(true);
updateClock(true);
} else if (action === 'seek') {
state.pos = clamp(+bar || 1, 1, 32);
E.seek(state.pos);
updatePlayhead(true);
updateClock(true);
}
return { playing: E.isPlaying(), bar: +E.getPos().toFixed(2) };
}
function setMacroUI(name, value) {
const el = document.querySelector('.knob[data-knob="' + name + '"]');
if (!el) return null;
el.dataset.value = clamp(+value, 0, 1);
paintKnob(el);
return +el.dataset.value;
}
window.ZHILA = {
state: state,
engine: E,
setMode: setMode,
addStoneClip: addStoneClip,
buildNave: buildNave,
buildLattice: buildLattice,
stones: stonesSummary,
addStoneAt: addStoneAt,
moveStoneAt: moveStoneAt,
deleteStoneAt: deleteStoneAt,
setTempo: setTempoValue,
transport: transportAt,
setMacroUI: setMacroUI,
shape: function () {
return { density: +($('#s-density') || {}).value / 100, depth: +($('#s-depth') || {}).value / 100, grain: +($('#s-grain') || {}).value / 100 };
},
undo: undo,
redo: redo,
commitSong: commitSong
};
/* the first light */
function shapeValue(id) {
const el = $('#' + id);
return el ? +el.value / 100 : 0.5;
}
function boot() {
$$('.shape .slider input').forEach(function (el) {
const out = el.parentNode.querySelector('output');
if (out) out.textContent = (+el.value / 100).toFixed(2);
el.addEventListener('input', function () {
if (out) out.textContent = (+el.value / 100).toFixed(2);
});
});
buildAll();
requestAnimationFrame(function () {
document.body.classList.add('is-ready');
requestAnimationFrame(frame);
});
if (document.fonts && document.fonts.ready) {
document.fonts.ready.then(function () {
if (fontsSettled) return;
fontsSettled = true;
relayout();
});
}
}
if (document.readyState === 'complete' || document.readyState === 'interactive') {
window.setTimeout(boot, 0);
} else {
document.addEventListener('DOMContentLoaded', boot);
}
})();