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