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