/* 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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 '{ ' + pairs.map(function (pair) {
const val = pair[2] ? '' + pair[1] + ''
: '"' + pair[1] + '"';
return '"' + pair[0] + '": ' + val;
}).join(', ') + ' }';
}
function addYou(text) {
const li = document.createElement('li');
li.className = 'call call--you';
li.innerHTML = '
';
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 =
'' +
'' + clockTime() + '' +
'' +
'' + opts.src + '' +
'' + opts.ms + '' +
'' +
'
' +
'';
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 = '';
} else if (kind === 'found') {
inner = '';
} else if (kind === 'loan') {
inner = '';
} else if (kind === 'gift') {
inner = '';
}
return '' + 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 += '';
out += '';
out += '';
out += '';
out += '';
out += '';
out += '';
out += '';
out += '';
out += '';
out += '';
out += '';
out += '';
/* 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 += '';
out += '';
out += '';
out += '';
});
/* 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 += '';
}
/* 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 += '';
});
/* the gate: where the road meets the city */
out += '';
/* 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 += '';
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 += '' + esc(info) + '';
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 += '';
out += '';
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 += '';
}
}
if (hpx > 16 && wpx > 26) {
const lp = bil(0.04, 0.2);
out += '' + clip.name + '';
if (hpx > 54 && wpx > 90) {
const rp = bil(0.96, 0.2);
out += '' + clip.a + '–' + clip.b + '';
}
if (clip.ring && hpx > 24) {
const lp2 = bil(0.9, 0.8);
out += '';
}
}
out += '';
});
});
/* 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 += '' + lane.name + '';
});
/* the near edge: now */
out += '';
out += '';
/* 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 += '';
out += '' + b + '';
});
}
/* playhead */
out += '';
out += '';
out += '';
out += '';
out += '';
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 = '
' + lane.name + '';
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 = '';
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') + '.' + String(cs).padStart(2, '0') + '';
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);
}
})();