P0: Rust engine, wire daemon and audio-reactive descent surface
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
# ZHILA
|
||||
|
||||
DAW, в котором звук лежит рельефом. Две комнаты: **nave** — собор, где камни укладываются и играются, и **quarry** — карьер под ним, где материал берут и режут. Сэмплирование здесь не «библиотека сэмплов», а карьер и кладка: у каждой вещи есть место, шрам, погода и клеймо.
|
||||
|
||||
Демка играет и слушается агентом: 32-тактовый луп из настоящих срезов, живой транспорт на аудиочасах, правка камней, undo, сохранение, импорт/запись, рендер в WAV.
|
||||
|
||||
## Запуск
|
||||
|
||||
```bash
|
||||
./start.sh
|
||||
```
|
||||
|
||||
Открыть **http://127.0.0.1:4173**. Скрипт берёт ключ из `DEEPSEEK_API_KEY` или из уже сохранённого ключа harness (`~/.dsh/.credentials.yaml`); ключ остаётся на сервере и в браузер не попадает. Порт можно поменять: `PORT=5000 ./start.sh`.
|
||||
|
||||
Без сервера `index.html` тоже открывается, но хор работает в локальном режиме (по правилам), без модели.
|
||||
|
||||
## Агент
|
||||
|
||||
Встроенный агент — это не чат сбоку, а тот же набор команд, что у руки. Модель (`deepseek-chat`) получает 14 инструментов и действует ими в живом проекте:
|
||||
|
||||
`get_state`, `list_cores`, `extract_core`, `fracture_core`, `strike_face`, `place_stone`, `move_stone`, `delete_stone`, `set_weather`, `set_rings`, `set_tempo`, `transport`, `open_core`, `set_macros`.
|
||||
|
||||
- Консоль `choir` показывает настоящий трейс: инструмент, аргументы, результат, время. Ответ — одна короткая реплика на языке пользователя.
|
||||
- Агент видит состояние (камни, керны, погоду, позицию), проверяет, где уже занято, и не кладёт камень поверх того же диапазона без просьбы.
|
||||
- `api live` в шапке — модель на связи; `api local` — сервера нет, работает локальный дирижёр по правилам (тоже через инструменты).
|
||||
- Ключ и модель живут только на сервере; сервер слушает 127.0.0.1 и принимает CORS только с localhost.
|
||||
|
||||
Примеры: «сделай темнее и добавь редкий шум на восемь тактов, потом включи», «возьми керн из лестничной клетки и нарежь его заново», «убери один камень из голоса, стало тесно», «покажи мне керн, из которого сделан бас».
|
||||
|
||||
MCP для внешних агентов ещё не поднят: те же 14 команд — это и есть будущая поверхность. Локальный API уже отделён от UI, поэтому сервер MCP — следующий шаг.
|
||||
|
||||
## nave
|
||||
|
||||
- `space` — пуск/пауза.
|
||||
- Камни: тащи вверх-вниз — время с привязкой к четверти такта; клик — выбрать; `Backspace` — закопать; `⌘/Ctrl+D` — дублировать; `⌘/Ctrl+Z` / `⇧⌘Z` — undo/redo.
|
||||
- `crystal` — живая партитура событий вокруг playhead (только просмотр).
|
||||
- `cilia` — уровни жил; красные точки — живой сигнал. `warmth / dust / threshold` правят мастер.
|
||||
- `render` — офлайн-рендер 32 тактов в `zhila-descent-04.wav` (~62 c, 48 кГц, стерео).
|
||||
- Поле «say what you want to hear» зовёт агента. Ползунки `shape` — предпочтения для следующего слоя; агент видит их в состоянии.
|
||||
|
||||
## quarry
|
||||
|
||||
- `core tray` — выбранный керн: настоящая волна, слои, шов, трещины. Линза читает глубину.
|
||||
- `fracture` реально перерезает керн: 16 граней получают новые границы, и камни из этого керна начинают звучать иначе.
|
||||
- `the face` — 16 граней. Удар звучит; `place` кладёт одиночный камень, `loop` — повторяющийся каждые 2 такта.
|
||||
- `weather` — `age / pressure / air` меняют климат всего микса, `heat` расстраивает голоса, `ghost` добавляет хвост источника. `grow ring` усиливает эрозию петель.
|
||||
- `extract`: `core` — новый процедурный керн, `file` — импорт, `room` — запись комнаты (`rec` тоже).
|
||||
- `ledger` — кто, откуда и с каким клеймом взял.
|
||||
|
||||
## Что работает по-настоящему
|
||||
|
||||
- **Звук.** Web Audio: срезы кернов как сэмплы, 16 граней, кольца с эрозией, климат, лимитер, панорама, анализ мастера.
|
||||
- **Транспорт.** Аудиочасы — мастер, lookahead-планировщик, луп, play/pause/stop/seek, темп на лету.
|
||||
- **Правка.** Выбор, перенос, удаление, дублирование, undo/redo, автосейв.
|
||||
- **Материал.** Процедурные керны, импорт, запись; поиск швов по огибающей; повторный `fracture`.
|
||||
- **Агент.** Реальная модель, 14 инструментов, трейс, проверка состояния, локальный фолбэк.
|
||||
- **Экспорт.** Офлайн-рендер в WAV с погодой, макросами и мастером.
|
||||
|
||||
## Что всё ещё макет
|
||||
|
||||
- Внешний MCP-сервер не поднят; `mcp`-чип — заявка на следующий шаг.
|
||||
- Клеймо и опись — журнал, не права и не согласие. Модель `draft → seal` описана, не реализована.
|
||||
- `crystal` не редактирует ноты; нет MIDI, редактора маппинга граней, trim/loop-точек, сендов/групп, дублей записи.
|
||||
- Импортированные и записанные керны живут только в памяти; после перезагрузки их камни молчат. Пять жил и панорама фиксированы.
|
||||
|
||||
## Файлы
|
||||
|
||||
- `index.html`, `style.css` — разметка и стиль
|
||||
- `engine.js` — аудиоядро: керны, срезы, голоса, климат, транспорт, рендер
|
||||
- `app.js` — nave: партитура, правка, история, анализ, кристалл, командный слой
|
||||
- `quarry.js` — карьер: лоток, грани, погода, кольца, опись
|
||||
- `agent.js` — хор в браузере: трейс, инструменты, локальный дирижёр
|
||||
- `server.js`, `agent-tools.js`, `start.sh` — локальный сервер и модель
|
||||
- `terrain.js` — рельеф и город
|
||||
- `preview.png`, `preview-playing.png`, `preview-agent.png`, `preview-quarry.png`, `preview-mobile.png`, `preview-quarry-mobile.png` — скриншоты
|
||||
- `preview.mp3` — 62 секунды базового рендера
|
||||
|
||||
Сброс проекта: очистить site data (localStorage `zhila.project.v3`).
|
||||
@@ -0,0 +1,227 @@
|
||||
/* ZHILA · agent — tools + the choir's brief. Shared by the local server. */
|
||||
|
||||
const SYSTEM = `You are the Choir, the agent embedded in ZHILA — a DAW where sound is terrain and samples are stones cut from cores.
|
||||
|
||||
The instrument has two rooms. "nave" is the arrangement: five veins (bass, crack, voice, noise, silt) laid as a masonry of stones over a 32-bar loop. "quarry" is the material: cores are recordings, 16 faces are cut slices of a core, rings are loops that erode per cycle, weather shapes the whole mix.
|
||||
|
||||
You act only through the tools. Never claim a change that a tool did not return. Never invent ids: call get_state or list_cores when unsure.
|
||||
|
||||
A good layer is material, not wallpaper:
|
||||
- a low pulse or bed on bass or voice, a mid texture on crack or noise, sparse accents on silt;
|
||||
- rings repeat (ring_bars > 0); 0 places a one-shot;
|
||||
- use faces that list_cores ranks as loud, and vary pitch by -12..+12 semitones;
|
||||
- leave space: 1–3 new stones is usually enough; do not fill every bar;
|
||||
- weather changes the whole piece: age darkens, pressure saturates, air opens the room, heat detunes;
|
||||
- if the user asks to hear something, use strike_face; if they ask to see material, use open_core.
|
||||
|
||||
Place stones with explicit bars and lengths. start_bar is 1..32, length_bars 1..16, ring_bars 0..8. Place in the vein whose role fits the sound.
|
||||
|
||||
Do not narrate tool use and do not announce what you are about to do. Call the tools, then give exactly one short final report. Answer in the user's language, terse and concrete, one or two sentences about what you took or changed. When speaking Russian, keep the instrument's field names in English (core, face, vein, ring, stone, weather, seal) instead of translating them. No markdown lists, no headings, no emoji. Do not explain the interface unless asked.
|
||||
|
||||
Before placing, look at the existing stones in get_state. Do not cover the exact same vein and bar range as an existing stone unless the user asked to double or layer it. If the requested range is taken, shift, shorten, or choose another vein and say so.`;
|
||||
|
||||
const TOOLS = [
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'get_state',
|
||||
description: 'Current project state: tempo, transport, weather, rings, cores, every stone with its id/vein/bars/ring/core/face, and the current selection.',
|
||||
parameters: { type: 'object', properties: {}, additionalProperties: false }
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'list_cores',
|
||||
description: 'List cores in the archive with duration, seal, and the indices of the loudest faces.',
|
||||
parameters: { type: 'object', properties: {}, additionalProperties: false }
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'extract_core',
|
||||
description: 'Take a new procedural core into the archive (the choir goes out for material). Returns the new core.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', description: 'A short name for the place or material.' },
|
||||
seal: { type: 'string', enum: ['found', 'taken', 'gift', 'loan', 'unknown'] }
|
||||
},
|
||||
additionalProperties: false
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'fracture_core',
|
||||
description: 'Re-cut a core into sixteen new faces. Every stone cut from this core changes its slice.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: { core_id: { type: 'string' } },
|
||||
required: ['core_id'],
|
||||
additionalProperties: false
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'strike_face',
|
||||
description: 'Audition one face through the weather. Use this to let the user hear material.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
core_id: { type: 'string' },
|
||||
face: { type: 'integer', minimum: 0, maximum: 15 },
|
||||
velocity: { type: 'number', minimum: 0, maximum: 1 }
|
||||
},
|
||||
required: ['core_id', 'face'],
|
||||
additionalProperties: false
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'place_stone',
|
||||
description: 'Place a stone in the arrangement. A stone is a face of a core, cut into a vein over bars; ring_bars 0 plays once, ring_bars 2 repeats every two bars.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
vein: { type: 'string', enum: ['bass', 'crack', 'voice', 'noise', 'silt'] },
|
||||
core_id: { type: 'string' },
|
||||
face: { type: 'integer', minimum: 0, maximum: 15 },
|
||||
start_bar: { type: 'number', minimum: 1, maximum: 32 },
|
||||
length_bars: { type: 'number', minimum: 1, maximum: 16 },
|
||||
ring_bars: { type: 'number', minimum: 0, maximum: 8 },
|
||||
pitch: { type: 'number', minimum: -12, maximum: 12 },
|
||||
gain: { type: 'number', minimum: 0.1, maximum: 1.2 },
|
||||
name: { type: 'string' }
|
||||
},
|
||||
required: ['vein', 'core_id', 'face', 'start_bar', 'length_bars'],
|
||||
additionalProperties: false
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'move_stone',
|
||||
description: 'Move an existing stone in time, keeping its length. Get ids from get_state.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
stone_id: { type: 'integer' },
|
||||
start_bar: { type: 'number', minimum: 1, maximum: 32 }
|
||||
},
|
||||
required: ['stone_id', 'start_bar'],
|
||||
additionalProperties: false
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'delete_stone',
|
||||
description: 'Bury a stone (remove it from the arrangement).',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: { stone_id: { type: 'integer' } },
|
||||
required: ['stone_id'],
|
||||
additionalProperties: false
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'set_weather',
|
||||
description: 'Shape the climate of the whole piece: age (dark/fossil), pressure (saturation), air (room), heat (detune). Omitted values stay unchanged.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
age: { type: 'number', minimum: 0, maximum: 1 },
|
||||
pressure: { type: 'number', minimum: 0, maximum: 1 },
|
||||
air: { type: 'number', minimum: 0, maximum: 1 },
|
||||
heat: { type: 'number', minimum: 0, maximum: 1 }
|
||||
},
|
||||
additionalProperties: false
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'set_rings',
|
||||
description: 'Set how many growth rings the loops carry; more rings mean stronger erosion on every cycle.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: { count: { type: 'integer', minimum: 0, maximum: 12 } },
|
||||
required: ['count'],
|
||||
additionalProperties: false
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'set_tempo',
|
||||
description: 'Set the tempo in BPM (40..220).',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: { bpm: { type: 'number', minimum: 40, maximum: 220 } },
|
||||
required: ['bpm'],
|
||||
additionalProperties: false
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'transport',
|
||||
description: 'Play, pause, stop or seek the transport. seek needs a bar.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
action: { type: 'string', enum: ['play', 'pause', 'stop', 'seek'] },
|
||||
bar: { type: 'number', minimum: 1, maximum: 32 }
|
||||
},
|
||||
required: ['action'],
|
||||
additionalProperties: false
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'open_core',
|
||||
description: 'Descend to the quarry and show a core in the tray.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: { core_id: { type: 'string' } },
|
||||
required: ['core_id'],
|
||||
additionalProperties: false
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'set_macros',
|
||||
description: 'Master macros: warmth (tilt), dust (hiss), threshold (limiter). 0..1.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
warmth: { type: 'number', minimum: 0, maximum: 1 },
|
||||
dust: { type: 'number', minimum: 0, maximum: 1 },
|
||||
threshold: { type: 'number', minimum: 0, maximum: 1 }
|
||||
},
|
||||
additionalProperties: false
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
module.exports = { SYSTEM, TOOLS };
|
||||
@@ -0,0 +1,470 @@
|
||||
/* ZHILA · agent
|
||||
the choir: a model on the local server, acting through the same verbs as the hand.
|
||||
without a server or a key it falls back to a local conductor — still using the tools. */
|
||||
|
||||
(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 esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||
const API = location.protocol === 'file:' ? 'http://127.0.0.1:4173' : '';
|
||||
|
||||
const E = window.ZHILA_ENGINE;
|
||||
const Z = window.ZHILA;
|
||||
const Q = window.ZHILA_QUARRY;
|
||||
|
||||
const logEl = $('#log');
|
||||
const stateEl = $('#agent-state');
|
||||
const stateText = $('#agent-state-text');
|
||||
const netText = $('#net-text');
|
||||
|
||||
const TOOL_NAMES = ['get_state', 'list_cores', 'extract_core', 'fracture_core', 'strike_face',
|
||||
'place_stone', 'move_stone', 'delete_stone', 'set_weather', 'set_rings',
|
||||
'set_tempo', 'transport', 'open_core', 'set_macros'];
|
||||
|
||||
let live = false;
|
||||
let busy = false;
|
||||
let history = [];
|
||||
let welcomed = false;
|
||||
|
||||
function timeNow() {
|
||||
const d = new Date();
|
||||
return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
|
||||
}
|
||||
|
||||
function scrollLog() {
|
||||
if (logEl) logEl.scrollTop = logEl.scrollHeight;
|
||||
}
|
||||
|
||||
function setStatus(text, thinking) {
|
||||
if (!stateText) return;
|
||||
stateText.textContent = text;
|
||||
if (stateEl) stateEl.classList.toggle('is-thinking', !!thinking);
|
||||
}
|
||||
|
||||
function addSay(text) {
|
||||
if (!logEl || !text) return;
|
||||
const li = document.createElement('li');
|
||||
li.className = 'call call--say';
|
||||
li.innerHTML = '<span class="call-t"></span><p></p>';
|
||||
li.querySelector('.call-t').textContent = timeNow();
|
||||
li.querySelector('p').textContent = text;
|
||||
logEl.appendChild(li);
|
||||
scrollLog();
|
||||
}
|
||||
|
||||
function addYou(text) {
|
||||
if (!logEl) return;
|
||||
const li = document.createElement('li');
|
||||
li.className = 'call call--you';
|
||||
li.innerHTML = '<span class="call-t"></span><p></p>';
|
||||
li.querySelector('.call-t').textContent = timeNow();
|
||||
li.querySelector('p').textContent = '\u201C' + text + '\u201D';
|
||||
logEl.appendChild(li);
|
||||
scrollLog();
|
||||
}
|
||||
|
||||
function argsHTML(args) {
|
||||
const keys = Object.keys(args || {});
|
||||
if (!keys.length) return '<span class="p">{ }</span>';
|
||||
return '<span class="p">{ </span>' + keys.map(function (k) {
|
||||
let v = args[k];
|
||||
let val;
|
||||
if (typeof v === 'string') val = '"' + esc(v) + '"';
|
||||
else if (typeof v === 'object' && v !== null) val = esc(JSON.stringify(v));
|
||||
else val = esc(String(v));
|
||||
return '<span class="k">"' + esc(k) + '"</span><span class="p">: </span><span class="v">' + val + '</span>';
|
||||
}).join('<span class="p">, </span>') + '<span class="p"> }</span>';
|
||||
}
|
||||
|
||||
function argLine(name, a) {
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
const pitch = n => n ? (n > 0 ? '+' : '') + n + 'st' : '';
|
||||
switch (name) {
|
||||
case 'place_stone':
|
||||
return [a.vein, a.core_id + ' f' + pad(a.face), a.start_bar + '–' + (+a.start_bar + +a.length_bars),
|
||||
a.ring_bars ? 'ring ' + a.ring_bars : 'once', pitch(a.pitch), a.gain ? 'gain ' + a.gain : ''].filter(Boolean).join(' · ');
|
||||
case 'strike_face':
|
||||
return a.core_id + ' f' + pad(a.face);
|
||||
case 'extract_core':
|
||||
return a.name || 'new core';
|
||||
case 'fracture_core':
|
||||
case 'open_core':
|
||||
return a.core_id;
|
||||
case 'move_stone':
|
||||
return 'stone ' + a.stone_id + ' → bar ' + a.start_bar;
|
||||
case 'delete_stone':
|
||||
return 'stone ' + a.stone_id;
|
||||
case 'set_weather':
|
||||
return ['age', 'pressure', 'air', 'heat'].filter(function (k) { return a[k] != null; })
|
||||
.map(function (k) { return k + ' ' + a[k]; }).join(' · ');
|
||||
case 'set_rings':
|
||||
return a.count + ' rings';
|
||||
case 'set_tempo':
|
||||
return a.bpm + ' bpm';
|
||||
case 'transport':
|
||||
return a.action + (a.bar ? ' · bar ' + a.bar : '');
|
||||
case 'set_macros':
|
||||
return ['warmth', 'dust', 'threshold'].filter(function (k) { return a[k] != null; })
|
||||
.map(function (k) { return k + ' ' + a[k]; }).join(' · ');
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function addTool(name, args, source) {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'call call--tool';
|
||||
li.innerHTML =
|
||||
'<div class="call-head">' +
|
||||
'<span class="call-t">' + timeNow() + '</span>' +
|
||||
'<span class="call-name"></span>' +
|
||||
'<span class="call-src">' + (source || 'api') + '</span>' +
|
||||
'<span class="call-ms">…</span>' +
|
||||
'</div>' +
|
||||
'<div class="call-args call-args--line">' + esc(argLine(name, args)) + '</div>';
|
||||
li.querySelector('.call-name').textContent = name;
|
||||
li.title = JSON.stringify(args);
|
||||
logEl.appendChild(li);
|
||||
scrollLog();
|
||||
return li;
|
||||
}
|
||||
|
||||
function summarize(name, res) {
|
||||
if (!res) return '';
|
||||
if (res.error) return 'error: ' + res.error;
|
||||
switch (name) {
|
||||
case 'get_state':
|
||||
return (res.stones ? res.stones.length : 0) + ' stones · bar ' + res.transport.bar + ' · ' + res.cores.length + ' cores · ' + res.view;
|
||||
case 'list_cores':
|
||||
return res.cores.length + ' cores: ' + res.cores.map(function (c) { return c.id; }).join(', ');
|
||||
case 'extract_core':
|
||||
return res.name + ' · ' + res.dur + 's · ' + res.seal;
|
||||
case 'fracture_core':
|
||||
return res.id + ' · re-cut · ' + res.loud.slice(0, 3).join(', ');
|
||||
case 'strike_face':
|
||||
return 'struck ' + res.core + ' f' + String(res.face + 1).padStart(2, '0');
|
||||
case 'place_stone':
|
||||
return res.vein + ' · bars ' + res.bars[0] + '–' + res.bars[1] + (res.ring ? ' · ring ' + res.ring : ' · once');
|
||||
case 'move_stone':
|
||||
return 'bars ' + res.bars[0] + '–' + res.bars[1];
|
||||
case 'delete_stone':
|
||||
return 'buried';
|
||||
case 'set_weather':
|
||||
return Object.keys(res.weather).map(function (k) { return k + ' ' + (+res.weather[k]).toFixed(2); }).join(' · ');
|
||||
case 'set_rings':
|
||||
return 'rings ' + res.rings;
|
||||
case 'set_tempo':
|
||||
return res.bpm + ' bpm';
|
||||
case 'transport':
|
||||
return (res.playing ? 'playing' : 'paused') + ' · bar ' + res.bar;
|
||||
case 'open_core':
|
||||
return 'quarry · ' + res.core;
|
||||
case 'set_macros':
|
||||
return Object.keys(res).map(function (k) { return k + ' ' + (+res[k]).toFixed(2); }).join(' · ');
|
||||
default:
|
||||
return JSON.stringify(res).slice(0, 140);
|
||||
}
|
||||
}
|
||||
|
||||
function finishTool(li, ms, res) {
|
||||
if (!li) return;
|
||||
const t = li.querySelector('.call-ms');
|
||||
if (t) t.textContent = ms;
|
||||
const name = li.querySelector('.call-name').textContent;
|
||||
const read = name === 'get_state' || name === 'list_cores';
|
||||
const line = document.createElement('div');
|
||||
line.className = 'call-result';
|
||||
line.textContent = (res && res.error ? 'error · ' : (read ? '' : 'ok · ')) + summarize(name, res);
|
||||
if (res && res.error) line.style.color = 'rgba(232,64,42,.8)';
|
||||
li.appendChild(line);
|
||||
scrollLog();
|
||||
}
|
||||
|
||||
/* ————— the verbs ————— */
|
||||
|
||||
async function exec(name, a) {
|
||||
a = a || {};
|
||||
if (!E) throw new Error('no engine');
|
||||
switch (name) {
|
||||
case 'get_state': {
|
||||
return {
|
||||
transport: { playing: E.isPlaying(), bar: +E.getPos().toFixed(2), bpm: E.getBpm() },
|
||||
weather: Q ? Q.weather() : E.weather,
|
||||
rings: Q ? Q.rings() : E.rings,
|
||||
cores: E.cores.map(function (c) {
|
||||
return { id: c.id, name: c.name, dur: +c.dur.toFixed(1), seal: c.seal, loud: c.ranked.slice(0, 4) };
|
||||
}),
|
||||
stones: Z.stones(),
|
||||
shape: Z.shape(),
|
||||
view: document.body.dataset.view,
|
||||
selected_core: Q && Q.selectedCore() ? Q.selectedCore().id : null
|
||||
};
|
||||
}
|
||||
case 'list_cores': {
|
||||
return {
|
||||
cores: E.cores.map(function (c) {
|
||||
return {
|
||||
id: c.id, name: c.name, dur: +c.dur.toFixed(1), seal: c.seal, faces: 16,
|
||||
loud: c.ranked.slice(0, 5),
|
||||
at: c.ranked.slice(0, 5).map(function (i) { return +c.faces[i].start.toFixed(2); })
|
||||
};
|
||||
})
|
||||
};
|
||||
}
|
||||
case 'extract_core': {
|
||||
const c = E.newCore({ name: (a.name || 'a core from the quarry').slice(0, 40), seal: a.seal || 'found' });
|
||||
E.addLog('cores.extract', c.name, c.seal, 'api');
|
||||
return { id: c.id, name: c.name, dur: +c.dur.toFixed(1), seal: c.seal, loud: c.ranked.slice(0, 4) };
|
||||
}
|
||||
case 'fracture_core': {
|
||||
const c = E.refracture(a.core_id);
|
||||
if (!c) throw new Error('no such core: ' + a.core_id);
|
||||
if (Q) Q.refresh();
|
||||
return { id: c.id, loud: c.ranked.slice(0, 4), faces: c.faces.length };
|
||||
}
|
||||
case 'strike_face': {
|
||||
const core = E.byId(a.core_id);
|
||||
if (!core) throw new Error('no such core: ' + a.core_id);
|
||||
const face = clamp(Math.round(+a.face || 0), 0, 15);
|
||||
E.strike(core.id, face, { pitch: 0, gain: 0.55 + 0.4 * (a.velocity == null ? 1 : a.velocity), ghost: true });
|
||||
return { core: core.id, face: face, struck: true };
|
||||
}
|
||||
case 'place_stone': {
|
||||
const r = Z.addStoneAt({
|
||||
vein: a.vein, core: a.core_id, face: a.face,
|
||||
start: a.start_bar, length: a.length_bars, ring: a.ring_bars,
|
||||
pitch: a.pitch, gain: a.gain, name: a.name, actor: 'api'
|
||||
});
|
||||
if (r.error) throw new Error(r.error);
|
||||
return r;
|
||||
}
|
||||
case 'move_stone': {
|
||||
const r = Z.moveStoneAt(a.stone_id, a.start_bar);
|
||||
if (r.error) throw new Error(r.error);
|
||||
return r;
|
||||
}
|
||||
case 'delete_stone': {
|
||||
const r = Z.deleteStoneAt(a.stone_id);
|
||||
if (r.error) throw new Error(r.error);
|
||||
return r;
|
||||
}
|
||||
case 'set_weather': {
|
||||
const w = Q ? Q.setWeatherUI({ age: a.age, pressure: a.pressure, air: a.air, heat: a.heat }) : E.weather;
|
||||
return { weather: w };
|
||||
}
|
||||
case 'set_rings': {
|
||||
return { rings: Q ? Q.setRingsUI(a.count) : E.rings };
|
||||
}
|
||||
case 'set_tempo':
|
||||
return { bpm: Z.setTempo(a.bpm) };
|
||||
case 'transport':
|
||||
return Z.transport(a.action, a.bar);
|
||||
case 'open_core': {
|
||||
if (Q) { Q.setView('quarry'); Q.focusCore(a.core_id); }
|
||||
return { core: a.core_id, view: 'quarry' };
|
||||
}
|
||||
case 'set_macros': {
|
||||
const out = {};
|
||||
['warmth', 'dust', 'threshold'].forEach(function (k) {
|
||||
if (a[k] != null) out[k] = Z.setMacroUI(k, a[k]);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
default:
|
||||
throw new Error('unknown tool: ' + name);
|
||||
}
|
||||
}
|
||||
|
||||
async function invokeTool(name, args, source) {
|
||||
const li = addTool(name, args, source);
|
||||
const t0 = performance.now();
|
||||
let res;
|
||||
try { res = await exec(name, args); }
|
||||
catch (e) { res = { error: String(e && e.message || e) }; }
|
||||
finishTool(li, ((performance.now() - t0) / 1000).toFixed(1) + 's', res);
|
||||
return res;
|
||||
}
|
||||
|
||||
/* ————— the model loop ————— */
|
||||
|
||||
function trimHistory() {
|
||||
const h = history.slice();
|
||||
while (h.length > 28) {
|
||||
const i = h.findIndex(function (m) { return m.role === 'user'; });
|
||||
if (i < 0) break;
|
||||
h.splice(0, i + 1);
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
async function modelSay(text) {
|
||||
history.push({ role: 'user', content: text });
|
||||
setStatus('thinking', true);
|
||||
let ran = 0;
|
||||
let lastError = null;
|
||||
let wireDown = false;
|
||||
for (let step = 0; step < 6; step++) {
|
||||
let r, j;
|
||||
try {
|
||||
r = await fetch(API + '/api/agent', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ messages: trimHistory() }),
|
||||
signal: AbortSignal.timeout(95000)
|
||||
});
|
||||
j = await r.json().catch(function () { return null; });
|
||||
} catch (e) {
|
||||
lastError = e;
|
||||
wireDown = true;
|
||||
break;
|
||||
}
|
||||
if (!r.ok) {
|
||||
lastError = new Error((j && j.error) || ('agent ' + r.status));
|
||||
if (j && j.mode === 'local') { wireDown = true; live = false; }
|
||||
break;
|
||||
}
|
||||
const m = j && j.message;
|
||||
if (!m) { lastError = new Error('empty reply'); break; }
|
||||
history.push(m);
|
||||
if (m.content && m.content.trim()) addSay(m.content.trim());
|
||||
if (!m.tool_calls || !m.tool_calls.length) { lastError = null; break; }
|
||||
for (let k = 0; k < m.tool_calls.length; k++) {
|
||||
const tc = m.tool_calls[k];
|
||||
const name = tc.function && tc.function.name;
|
||||
let args = {};
|
||||
try { args = JSON.parse((tc.function && tc.function.arguments) || '{}'); } catch (e) {}
|
||||
setStatus('calling ' + name, true);
|
||||
const res = await invokeTool(name, args, 'api');
|
||||
ran++;
|
||||
history.push({ role: 'tool', tool_call_id: tc.id, name: name, content: JSON.stringify(res) });
|
||||
}
|
||||
}
|
||||
if (lastError) {
|
||||
if (wireDown) {
|
||||
live = false;
|
||||
document.body.classList.add('agent-offline');
|
||||
if (netText) netText.textContent = 'local';
|
||||
}
|
||||
addSay('the choir lost the wire: ' + lastError.message + '.');
|
||||
if (!ran) localSay(text, true);
|
||||
}
|
||||
}
|
||||
|
||||
/* ————— local conductor (no model) ————— */
|
||||
|
||||
async function localSay(text, afterError) {
|
||||
const t = text.toLowerCase();
|
||||
const core = (Q && Q.selectedCore()) || E.cores[0];
|
||||
if (!core) {
|
||||
addSay('there is no material in the archive yet.');
|
||||
return;
|
||||
}
|
||||
const done = [];
|
||||
const wantsStop = /(stop|стоп|пауза|pause)/.test(t);
|
||||
const wantsPlay = /(play|играй|пуск|запусти|start)/.test(t);
|
||||
if (wantsStop) { await invokeTool('transport', { action: 'pause' }, 'local'); done.push('paused'); }
|
||||
else if (wantsPlay) { await invokeTool('transport', { action: 'play' }, 'local'); done.push('the wheels are turning'); }
|
||||
|
||||
if (/(fracture|cut|реж|трещин|разрез)/.test(t)) {
|
||||
await invokeTool('fracture_core', { core_id: core.id }, 'local');
|
||||
done.push('re-cut ' + core.id);
|
||||
} else if (/(take|bring|extract|найд|принес|достань|новый|room|поле|запись)/.test(t)) {
|
||||
const c = await invokeTool('extract_core', { name: text.slice(0, 42), seal: 'found' }, 'local');
|
||||
done.push('the choir brought ' + c.name);
|
||||
} else if (/(dark|темн|стар|old|fossil|истощ)/.test(t)) {
|
||||
const w = await invokeTool('set_weather', { age: 0.72, air: 0.3 }, 'local');
|
||||
done.push('weather: age 0.72, air 0.30');
|
||||
} else if (/(wet|мокр|room|воздух|air|простор)/.test(t)) {
|
||||
await invokeTool('set_weather', { air: 0.74, pressure: 0.3 }, 'local');
|
||||
done.push('weather: air 0.74, pressure 0.30');
|
||||
}
|
||||
|
||||
const wantsPlace = /(place|add|layer|слой|добавь|постав|loop|петл|пульс|bass|низ|голос|шум|звук|дыш)/.test(t);
|
||||
if (wantsPlace || !done.length) {
|
||||
let vein = 'silt';
|
||||
if (/(bass|низ|бас|пол)/.test(t)) vein = 'bass';
|
||||
else if (/(voice|голос|хор|петь)/.test(t)) vein = 'voice';
|
||||
else if (/(noise|шум|воздух|ветер)/.test(t)) vein = 'noise';
|
||||
else if (/(crack|трещ|грит)/.test(t)) vein = 'crack';
|
||||
const face = E.rankFace(core.id, vein === 'bass' ? 0 : 1);
|
||||
const start = Math.min(30, Math.ceil(E.getPos()) + 1);
|
||||
const ring = /(once|один|раз|one)/.test(t) ? 0 : 2;
|
||||
const pitch = vein === 'bass' ? -5 : 0;
|
||||
const res = await invokeTool('place_stone', {
|
||||
vein: vein, core_id: core.id, face: face,
|
||||
start_bar: start, length_bars: 8, ring_bars: ring, pitch: pitch, gain: 0.8
|
||||
}, 'local');
|
||||
if (!res.error) done.push('laid ' + res.name + ' in ' + res.vein + ', bars ' + res.bars[0] + '–' + res.bars[1]);
|
||||
}
|
||||
|
||||
addSay((afterError ? 'the wire is down, so i worked by hand. ' : '') + (done.length ? done.join('. ') + '.' : 'i listened, but found nothing to change.'));
|
||||
}
|
||||
|
||||
async function say(text) {
|
||||
text = String(text || '').trim();
|
||||
if (!text) return;
|
||||
if (busy) {
|
||||
addSay('one at a time — the choir is still working.');
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
addYou(text);
|
||||
try {
|
||||
if (live) {
|
||||
await modelSay(text);
|
||||
} else {
|
||||
setStatus('local', false);
|
||||
await localSay(text, false);
|
||||
}
|
||||
} catch (e) {
|
||||
addSay('the choir failed: ' + (e && e.message || e));
|
||||
} finally {
|
||||
busy = false;
|
||||
setStatus(live ? 'listening' : 'local', false);
|
||||
}
|
||||
}
|
||||
|
||||
/* ————— boot ————— */
|
||||
|
||||
async function init() {
|
||||
const meta = $('.state-meta');
|
||||
if (meta) meta.textContent = TOOL_NAMES.length + ' tools';
|
||||
try {
|
||||
const r = await fetch(API + '/api/agent', { signal: AbortSignal.timeout(3000) });
|
||||
const j = await r.json();
|
||||
live = !!j.live;
|
||||
} catch (e) {
|
||||
live = false;
|
||||
}
|
||||
document.body.classList.toggle('agent-offline', !live);
|
||||
if (netText) netText.textContent = live ? 'live' : 'local';
|
||||
const chip = $('.chip');
|
||||
if (chip) {
|
||||
chip.classList.toggle('is-live', live);
|
||||
chip.title = live ? 'Embedded agent: live model on the local server' : 'Embedded agent: local conductor (no model on the wire)';
|
||||
}
|
||||
setStatus(live ? 'listening' : 'local', false);
|
||||
if (!welcomed) {
|
||||
welcomed = true;
|
||||
addSay(live
|
||||
? 'the choir is listening. say what should be heard — i can take cores, cut faces, lay stones, or change the weather.'
|
||||
: 'the choir is local: no model on the wire. i can still take, cut, and lay by rule.');
|
||||
}
|
||||
}
|
||||
|
||||
window.ZHILA_AGENT = {
|
||||
say: say,
|
||||
isLive: function () { return live; },
|
||||
isBusy: function () { return busy; },
|
||||
tools: TOOL_NAMES.length
|
||||
};
|
||||
|
||||
if (document.readyState === 'complete' || document.readyState === 'interactive') {
|
||||
window.setTimeout(init, 250);
|
||||
} else {
|
||||
document.addEventListener('DOMContentLoaded', function () { window.setTimeout(init, 250); });
|
||||
}
|
||||
})();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,825 @@
|
||||
/* ZHILA · engine
|
||||
audio clock, sample slices, voices, climate, transport, render.
|
||||
one graph for listening and for offline bounce. */
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const clamp = (v, a, b) => v < a ? a : v > b ? b : v;
|
||||
const mulberry = function (seed) {
|
||||
let a = seed | 0;
|
||||
return function () {
|
||||
a = a + 0x6D2B79F5 | 0;
|
||||
let t = Math.imul(a ^ a >>> 15, 1 | a);
|
||||
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
|
||||
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
||||
};
|
||||
};
|
||||
|
||||
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
const sr = ctx.sampleRate;
|
||||
|
||||
const E = {
|
||||
ctx: ctx,
|
||||
sr: sr,
|
||||
cores: [],
|
||||
song: { bpm: 128, bars: 32, stones: [] },
|
||||
weather: { age: 0.34, pressure: 0.52, air: 0.41, heat: 0.28 },
|
||||
rings: 4,
|
||||
muted: false,
|
||||
playing: false,
|
||||
log: [],
|
||||
listeners: { cores: [], log: [], render: [] }
|
||||
};
|
||||
|
||||
/* ————— graph ————— */
|
||||
|
||||
function impulse(c, seconds) {
|
||||
const n = Math.floor(c.sampleRate * seconds);
|
||||
const buf = c.createBuffer(2, n, c.sampleRate);
|
||||
for (let ch = 0; ch < 2; ch++) {
|
||||
const d = buf.getChannelData(ch);
|
||||
for (let i = 0; i < n; i++) {
|
||||
d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / n, 2.6);
|
||||
}
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
function graph(c) {
|
||||
const master = c.createGain();
|
||||
master.gain.value = E.muted ? 0 : 0.85;
|
||||
|
||||
const limiter = c.createDynamicsCompressor();
|
||||
limiter.threshold.value = -7;
|
||||
limiter.knee.value = 8;
|
||||
limiter.ratio.value = 10;
|
||||
limiter.attack.value = 0.004;
|
||||
limiter.release.value = 0.22;
|
||||
|
||||
const analyser = c.createAnalyser();
|
||||
analyser.fftSize = 2048;
|
||||
analyser.smoothingTimeConstant = 0.72;
|
||||
|
||||
const climateIn = c.createGain();
|
||||
const age = c.createBiquadFilter();
|
||||
age.type = 'lowpass';
|
||||
age.Q.value = 0.6;
|
||||
const tilt = c.createBiquadFilter();
|
||||
tilt.type = 'highshelf';
|
||||
tilt.frequency.value = 1800;
|
||||
tilt.gain.value = (macros.warmth - 0.5) * 10;
|
||||
const drive = c.createGain();
|
||||
const shaper = c.createWaveShaper();
|
||||
const curve = new Float32Array(1024);
|
||||
for (let i = 0; i < 1024; i++) {
|
||||
const x = (i * 2) / 1024 - 1;
|
||||
curve[i] = Math.tanh(x * 2.1);
|
||||
}
|
||||
shaper.curve = curve;
|
||||
shaper.oversample = '2x';
|
||||
const post = c.createGain();
|
||||
const dry = c.createGain();
|
||||
const verb = c.createConvolver();
|
||||
verb.buffer = impulse(c, 2.4);
|
||||
const wet = c.createGain();
|
||||
|
||||
climateIn.connect(age);
|
||||
age.connect(tilt);
|
||||
tilt.connect(drive);
|
||||
drive.connect(shaper);
|
||||
shaper.connect(post);
|
||||
post.connect(dry);
|
||||
dry.connect(limiter);
|
||||
post.connect(verb);
|
||||
verb.connect(wet);
|
||||
wet.connect(limiter);
|
||||
limiter.connect(master);
|
||||
master.connect(analyser);
|
||||
analyser.connect(c.destination);
|
||||
|
||||
const veins = [];
|
||||
const pans = [-0.34, -0.16, 0, 0.16, 0.34];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const g = c.createGain();
|
||||
g.gain.value = 0.6;
|
||||
const p = c.createStereoPanner ? c.createStereoPanner() : null;
|
||||
if (p) {
|
||||
p.pan.value = pans[i];
|
||||
g.connect(p);
|
||||
p.connect(climateIn);
|
||||
} else {
|
||||
g.connect(climateIn);
|
||||
}
|
||||
veins.push(g);
|
||||
}
|
||||
const strikeBus = c.createGain();
|
||||
strikeBus.gain.value = 0.8;
|
||||
strikeBus.connect(climateIn);
|
||||
|
||||
// dust: a permanent, quiet hiss in the climate
|
||||
const hissBuf = c.createBuffer(1, Math.floor(c.sampleRate * 2), c.sampleRate);
|
||||
const hd = hissBuf.getChannelData(0);
|
||||
for (let i = 0; i < hd.length; i++) hd[i] = Math.random() * 2 - 1;
|
||||
const hiss = c.createBufferSource();
|
||||
hiss.buffer = hissBuf;
|
||||
hiss.loop = true;
|
||||
const hissF = c.createBiquadFilter();
|
||||
hissF.type = 'highpass';
|
||||
hissF.frequency.value = 1200;
|
||||
const hissGain = c.createGain();
|
||||
hissGain.gain.value = macros.dust * 0.03;
|
||||
hiss.connect(hissF); hissF.connect(hissGain); hissGain.connect(tilt);
|
||||
hiss.start();
|
||||
|
||||
limiter.threshold.value = -20 + macros.threshold * 20;
|
||||
|
||||
return {
|
||||
master: master, limiter: limiter, analyser: analyser, climateIn: climateIn,
|
||||
age: age, tilt: tilt, drive: drive, post: post, dry: dry, wet: wet, verb: verb,
|
||||
veins: veins, strikeBus: strikeBus, hiss: hissGain
|
||||
};
|
||||
}
|
||||
|
||||
const macros = { warmth: 0.58, dust: 0.34, threshold: 0.72 };
|
||||
const G = graph(ctx);
|
||||
const freqData = new Uint8Array(G.analyser.frequencyBinCount);
|
||||
const timeData = new Uint8Array(G.analyser.fftSize);
|
||||
|
||||
function applyClimate(target, w) {
|
||||
const t = target.ctx ? target.ctx.currentTime : 0;
|
||||
target.age.frequency.setTargetAtTime(650 + (1 - w.age) * 11500, t, 0.08);
|
||||
target.drive.gain.setTargetAtTime(1 + w.pressure * 4.2, t, 0.08);
|
||||
target.post.gain.setTargetAtTime(0.72 / (1 + w.pressure * 1.1), t, 0.08);
|
||||
target.wet.gain.setTargetAtTime(0.035 + w.air * 0.52, t, 0.08);
|
||||
target.dry.gain.setTargetAtTime(1 - w.air * 0.28, t, 0.08);
|
||||
}
|
||||
G.ctx = ctx;
|
||||
applyClimate(G, E.weather);
|
||||
|
||||
E.setMacro = function (name, value) {
|
||||
macros[name] = clamp(+value, 0, 1);
|
||||
if (name === 'warmth') G.tilt.gain.setTargetAtTime((macros.warmth - 0.5) * 10, ctx.currentTime, 0.06);
|
||||
if (name === 'dust') G.hiss.gain.setTargetAtTime(macros.dust * 0.03, ctx.currentTime, 0.1);
|
||||
if (name === 'threshold') G.limiter.threshold.setTargetAtTime(-20 + macros.threshold * 20, ctx.currentTime, 0.1);
|
||||
};
|
||||
E.getMacro = function (name) { return macros[name]; };
|
||||
|
||||
/* ————— sound of the material ————— */
|
||||
|
||||
function synth(kind, seed) {
|
||||
const rnd = mulberry(seed * 131 + 7);
|
||||
const dur = kind === 'room' ? 15 : kind === 'rail' ? 12 : kind === 'rain' ? 7.6 : 9.6;
|
||||
const n = Math.floor(dur * sr);
|
||||
const buf = ctx.createBuffer(1, n, sr);
|
||||
const d = buf.getChannelData(0);
|
||||
let peak = 0;
|
||||
|
||||
if (kind === 'stair') {
|
||||
let lp = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const w = Math.random() * 2 - 1;
|
||||
lp += (w - lp) * 0.012;
|
||||
d[i] = lp * 0.9;
|
||||
peak = Math.max(peak, Math.abs(d[i]));
|
||||
}
|
||||
const hits = 11 + Math.floor(rnd() * 5);
|
||||
for (let k = 0; k < hits; k++) {
|
||||
const at = Math.floor((0.04 + rnd() * 0.88) * n);
|
||||
const f = 46 + rnd() * 46;
|
||||
const amp = 0.26 + rnd() * 0.34;
|
||||
const dec = 0.28 + rnd() * 0.75;
|
||||
const max = Math.min(n - at, Math.floor(dec * 3 * sr));
|
||||
for (let i = 0; i < max; i++) {
|
||||
const e = Math.exp(-i / (dec * sr));
|
||||
d[at + i] += Math.sin(TAU() * f * i / sr) * amp * e;
|
||||
}
|
||||
const tn = Math.min(n - at, Math.floor(0.05 * sr));
|
||||
let nlp = 0;
|
||||
for (let i = 0; i < tn; i++) {
|
||||
const w = Math.random() * 2 - 1;
|
||||
nlp += (w - nlp) * 0.2;
|
||||
d[at + i] += nlp * 0.32 * Math.exp(-i / (0.018 * sr));
|
||||
}
|
||||
}
|
||||
} else if (kind === 'rain') {
|
||||
let lp = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const w = Math.random() * 2 - 1;
|
||||
lp += (w - lp) * 0.52;
|
||||
d[i] = (w - lp) * 0.018;
|
||||
}
|
||||
const ticks = 640;
|
||||
for (let k = 0; k < ticks; k++) {
|
||||
const at = Math.floor(rnd() * (n - 800));
|
||||
const amp = 0.04 + rnd() * 0.2;
|
||||
const len = 40 + Math.floor(rnd() * 260);
|
||||
for (let i = 0; i < len; i++) {
|
||||
d[at + i] += (Math.random() * 2 - 1) * amp * Math.exp(-i / (0.0022 * sr));
|
||||
}
|
||||
}
|
||||
for (let k = 0; k < 14; k++) {
|
||||
const at = Math.floor(rnd() * (n - sr));
|
||||
const f = 900 + rnd() * 2800;
|
||||
const amp = 0.025 + rnd() * 0.045;
|
||||
const dec = 0.16 + rnd() * 0.4;
|
||||
for (let i = 0; i < Math.min(n - at, Math.floor(dec * 3 * sr)); i++) {
|
||||
d[at + i] += Math.sin(TAU() * f * i / sr) * amp * Math.exp(-i / (dec * sr));
|
||||
}
|
||||
}
|
||||
} else if (kind === 'room') {
|
||||
let brown = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const w = Math.random() * 2 - 1;
|
||||
brown = (brown + w * 0.02) * 0.999;
|
||||
const swell = 0.55 + 0.45 * Math.sin(TAU() * 0.055 * i / sr + seed);
|
||||
d[i] = brown * 5.2 * swell + Math.sin(TAU() * 41 * i / sr) * 0.012;
|
||||
}
|
||||
} else {
|
||||
let lp = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const w = Math.random() * 2 - 1;
|
||||
lp += (w - lp) * 0.006;
|
||||
d[i] = lp * 1.7;
|
||||
}
|
||||
const squeals = 3 + Math.floor(rnd() * 3);
|
||||
for (let k = 0; k < squeals; k++) {
|
||||
const at = Math.floor((0.1 + rnd() * 0.75) * n);
|
||||
const f0 = 320 + rnd() * 280;
|
||||
const f1 = f0 * (1.15 + rnd() * 0.5);
|
||||
const amp = 0.03 + rnd() * 0.035;
|
||||
const len = Math.min(n - at, Math.floor((0.9 + rnd() * 1.4) * sr));
|
||||
let ph = 0;
|
||||
for (let i = 0; i < len; i++) {
|
||||
const u = i / len;
|
||||
const f = f0 + (f1 - f0) * u;
|
||||
ph += TAU() * f / sr;
|
||||
d[at + i] += Math.sin(ph) * amp * Math.sin(Math.PI * Math.pow(u, 0.6)) * Math.exp(-u * 1.3);
|
||||
}
|
||||
}
|
||||
for (let k = 0; k < 4; k++) {
|
||||
const at = Math.floor(rnd() * 0.9 * n);
|
||||
const f = 38 + rnd() * 30;
|
||||
for (let i = 0; i < Math.min(n - at, Math.floor(1.2 * sr)); i++) {
|
||||
d[at + i] += Math.sin(TAU() * f * i / sr) * 0.16 * Math.exp(-i / (0.4 * sr));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < n; i++) peak = Math.max(peak, Math.abs(d[i]));
|
||||
const norm = 0.58 / (peak || 1);
|
||||
const fade = Math.floor(0.03 * sr);
|
||||
for (let i = 0; i < n; i++) {
|
||||
let g = norm;
|
||||
if (i < fade) g *= i / fade;
|
||||
if (i > n - fade) g *= (n - i) / fade;
|
||||
d[i] *= g;
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
function TAU() { return Math.PI * 2; }
|
||||
|
||||
function envelope(buffer, buckets) {
|
||||
const ch = buffer.getChannelData(0);
|
||||
const n = ch.length;
|
||||
const env = new Float32Array(buckets);
|
||||
const step = n / buckets;
|
||||
for (let i = 0; i < buckets; i++) {
|
||||
let m = 0;
|
||||
const from = Math.floor(i * step);
|
||||
const to = Math.min(n, Math.floor((i + 1) * step));
|
||||
for (let k = from; k < to; k += 2) {
|
||||
const v = Math.abs(ch[k]);
|
||||
if (v > m) m = v;
|
||||
}
|
||||
env[i] = m;
|
||||
}
|
||||
const mx = env.reduce(function (a, b) { return Math.max(a, b); }, 0.0001);
|
||||
for (let i = 0; i < buckets; i++) env[i] = Math.min(1, env[i] / mx);
|
||||
return env;
|
||||
}
|
||||
|
||||
function rmsOf(buffer, startSec, lenSec) {
|
||||
const ch = buffer.getChannelData(0);
|
||||
const a = Math.floor(startSec * buffer.sampleRate);
|
||||
const b = Math.min(ch.length, Math.floor((startSec + lenSec) * buffer.sampleRate));
|
||||
let s = 0, c = 0;
|
||||
for (let i = a; i < b; i += 3) { s += ch[i] * ch[i]; c++; }
|
||||
return c ? Math.sqrt(s / c) : 0;
|
||||
}
|
||||
|
||||
function makeFaces(buffer, variant) {
|
||||
const total = buffer.duration;
|
||||
const env = envelope(buffer, 400);
|
||||
const bounds = [0];
|
||||
for (let i = 1; i < 16; i++) {
|
||||
const base = Math.floor((i / 16) * 400);
|
||||
const span = 10;
|
||||
let best = base, bestV = Infinity;
|
||||
for (let k = Math.max(1, base - span); k < Math.min(399, base + span); k++) {
|
||||
if (env[k] < bestV) { bestV = env[k]; best = k; }
|
||||
}
|
||||
bounds.push(best / 400);
|
||||
}
|
||||
if (variant) {
|
||||
const rnd = mulberry(variant * 997 + 13);
|
||||
for (let i = 1; i < 16; i++) {
|
||||
const shift = (rnd() - 0.5) * 0.045;
|
||||
bounds[i] = clamp(bounds[i] + shift, bounds[i - 1] + 0.004, 0.995);
|
||||
}
|
||||
}
|
||||
bounds.push(1);
|
||||
const faces = [];
|
||||
for (let i = 0; i < 16; i++) {
|
||||
const s = bounds[i] * total;
|
||||
const e = bounds[i + 1] * total;
|
||||
faces.push({ start: s, len: Math.max(0.03, e - s), rms: rmsOf(buffer, s, Math.max(0.03, e - s)) });
|
||||
}
|
||||
const ranked = faces.map(function (f, i) { return i; }).sort(function (a, b) { return faces[b].rms - faces[a].rms; });
|
||||
const cuts = [faces[5].start, faces[10].start, faces[14].start];
|
||||
return { faces: faces, ranked: ranked, cuts: cuts };
|
||||
}
|
||||
|
||||
function addCore(opts) {
|
||||
const buffer = opts.buffer || synth(opts.kind || 'stair', opts.seed || 1);
|
||||
const met = makeFaces(buffer, opts.variant || 0);
|
||||
const core = {
|
||||
id: opts.id || 'core-' + (E.cores.length + 1),
|
||||
name: opts.name || 'untitled core',
|
||||
seal: opts.seal || 'taken',
|
||||
kind: opts.kind || 'stair',
|
||||
seed: opts.seed || 1,
|
||||
dur: buffer.duration,
|
||||
buffer: buffer,
|
||||
env: envelope(buffer, 220),
|
||||
faces: met.faces,
|
||||
ranked: met.ranked,
|
||||
cuts: met.cuts
|
||||
};
|
||||
E.cores.push(core);
|
||||
E.listeners.cores.forEach(function (fn) { fn(core); });
|
||||
return core;
|
||||
}
|
||||
|
||||
function rankFace(coreId, rank) {
|
||||
const c = byId(coreId);
|
||||
if (!c) return 0;
|
||||
return c.ranked[clamp(rank | 0, 0, 15)];
|
||||
}
|
||||
|
||||
function byId(id) {
|
||||
for (let i = 0; i < E.cores.length; i++) if (E.cores[i].id === id) return E.cores[i];
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ————— voices ————— */
|
||||
|
||||
const voices = [];
|
||||
|
||||
function playSlice(core, face, opts) {
|
||||
if (!core || !face) return;
|
||||
const when = opts.when || (ctx.currentTime + 0.01);
|
||||
const pitch = opts.pitch || 0;
|
||||
const rate = Math.pow(2, pitch / 12) * (1 + E.weather.heat * 0.03);
|
||||
const vol = clamp(opts.gain == null ? 0.8 : opts.gain, 0, 2);
|
||||
const dur = Math.max(0.04, face.len / rate);
|
||||
const src = ctx.createBufferSource();
|
||||
src.buffer = core.buffer;
|
||||
src.playbackRate.value = rate;
|
||||
const g = ctx.createGain();
|
||||
const atk = 0.004;
|
||||
const rel = Math.min(0.03, dur * 0.3);
|
||||
g.gain.setValueAtTime(0, when);
|
||||
g.gain.linearRampToValueAtTime(vol, when + atk);
|
||||
g.gain.setValueAtTime(vol, Math.max(when + atk, when + dur - rel));
|
||||
g.gain.linearRampToValueAtTime(0, when + dur);
|
||||
src.connect(g);
|
||||
const target = opts.bus || 0;
|
||||
if (opts.bus) g.connect(opts.bus);
|
||||
else g.connect(G.veins[clamp(target, 0, 4)] || G.veins[0]);
|
||||
src.start(when, clamp(face.start, 0, core.dur - 0.01));
|
||||
src.stop(when + dur + 0.01);
|
||||
const voice = { src: src, g: g, end: when + dur };
|
||||
voices.push(voice);
|
||||
src.onended = function () {
|
||||
const i = voices.indexOf(voice);
|
||||
if (i >= 0) voices.splice(i, 1);
|
||||
};
|
||||
if (voices.length > 30) {
|
||||
const old = voices.shift();
|
||||
try {
|
||||
old.g.gain.cancelScheduledValues(ctx.currentTime);
|
||||
old.g.gain.setTargetAtTime(0, ctx.currentTime, 0.01);
|
||||
old.src.stop(ctx.currentTime + 0.05);
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
function strike(coreId, faceIdx, opts) {
|
||||
opts = opts || {};
|
||||
resume();
|
||||
const core = byId(coreId);
|
||||
if (!core) return;
|
||||
const face = core.faces[faceIdx];
|
||||
if (!face) return;
|
||||
playSlice(core, face, {
|
||||
pitch: opts.pitch || 0,
|
||||
gain: opts.gain || 0.9,
|
||||
bus: G.strikeBus
|
||||
});
|
||||
if (opts.ghost) {
|
||||
const when = ctx.currentTime + 0.02;
|
||||
const src = ctx.createBufferSource();
|
||||
src.buffer = core.buffer;
|
||||
src.playbackRate.value = Math.pow(2, (opts.pitch || 0) / 12) * 0.5;
|
||||
const f = ctx.createBiquadFilter();
|
||||
f.type = 'lowpass';
|
||||
f.frequency.value = 900;
|
||||
const g = ctx.createGain();
|
||||
const dur = Math.min(1.2, face.len * 2.4);
|
||||
g.gain.setValueAtTime(0, when);
|
||||
g.gain.linearRampToValueAtTime(0.09, when + 0.05);
|
||||
g.gain.exponentialRampToValueAtTime(0.001, when + dur);
|
||||
src.connect(f); f.connect(g); g.connect(G.strikeBus);
|
||||
src.start(when, clamp(face.start, 0, core.dur - 0.01));
|
||||
src.stop(when + dur + 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
function stopVoices() {
|
||||
const now = ctx.currentTime;
|
||||
voices.splice(0).forEach(function (v) {
|
||||
try {
|
||||
v.g.gain.cancelScheduledValues(now);
|
||||
v.g.gain.setTargetAtTime(0, now, 0.015);
|
||||
v.src.stop(now + 0.08);
|
||||
} catch (e) {}
|
||||
});
|
||||
}
|
||||
|
||||
/* ————— song & scheduler ————— */
|
||||
|
||||
let events = [];
|
||||
let pass = 0, cursor = 0, nextTime = Infinity, timer = null;
|
||||
let transportStart = 0;
|
||||
let pausedPos = 7.5;
|
||||
|
||||
function barSec() { return 60 / E.song.bpm * 4; }
|
||||
function bps() { return E.song.bpm / 60 / 4; }
|
||||
function loopDur() { return E.song.bars * barSec(); }
|
||||
|
||||
function compile() {
|
||||
events = [];
|
||||
E.song.stones.forEach(function (s) {
|
||||
const core = byId(s.coreId);
|
||||
if (!core) return;
|
||||
const face = core.faces[s.face | 0];
|
||||
if (!face) return;
|
||||
const a = clamp(+s.a || 1, 1, E.song.bars);
|
||||
const b = clamp(+s.b || a + 1, a + 0.01, E.song.bars);
|
||||
const ring = Math.max(0, +s.ring || 0);
|
||||
const pitch = +s.pitch || 0;
|
||||
const gain = s.gain == null ? 0.8 : +s.gain;
|
||||
const vein = clamp(+s.vein || 0, 0, 4);
|
||||
if (ring > 0) {
|
||||
let cycle = 0;
|
||||
const endBar = b >= E.song.bars - 1e-6 ? b + 1e-6 : b - 1e-6;
|
||||
for (let bar = a; bar < endBar; bar += ring) {
|
||||
events.push({ bar: bar, core: core, face: face, pitch: pitch, gain: gain, vein: vein, cycle: cycle++ });
|
||||
}
|
||||
} else {
|
||||
events.push({ bar: a, core: core, face: face, pitch: pitch, gain: gain, vein: vein, cycle: 0 });
|
||||
}
|
||||
});
|
||||
events.sort(function (p, q) { return p.bar - q.bar; });
|
||||
}
|
||||
|
||||
function resetScheduler() {
|
||||
pass = 0; cursor = 0;
|
||||
nextTime = events.length
|
||||
? transportStart + (events[0].bar - 1) * barSec()
|
||||
: Infinity;
|
||||
}
|
||||
|
||||
function tick() {
|
||||
if (!E.playing || !events.length) return;
|
||||
const now = ctx.currentTime;
|
||||
const until = now + 0.18;
|
||||
let guard = 0;
|
||||
while (nextTime < until && guard++ < 800) {
|
||||
if (nextTime >= now - 0.02) {
|
||||
const ev = events[cursor];
|
||||
const growth = 1 + ev.cycle * 0.0006 * E.rings;
|
||||
playSlice(ev.core, ev.face, {
|
||||
when: nextTime,
|
||||
pitch: ev.pitch + ev.cycle * 0.01 * E.rings,
|
||||
gain: ev.gain * (1 - Math.min(0.35, ev.cycle * 0.004 * E.rings)) * growth,
|
||||
vein: ev.vein
|
||||
});
|
||||
}
|
||||
cursor++;
|
||||
if (cursor >= events.length) { cursor = 0; pass++; }
|
||||
nextTime = transportStart + pass * loopDur() + (events[cursor].bar - 1) * barSec();
|
||||
}
|
||||
}
|
||||
|
||||
function resume() {
|
||||
if (ctx.state === 'suspended') ctx.resume();
|
||||
}
|
||||
|
||||
E.play = function () {
|
||||
resume();
|
||||
if (E.playing) return;
|
||||
E.playing = true;
|
||||
transportStart = ctx.currentTime - (pausedPos - 1) / bps() + 0.06;
|
||||
resetScheduler();
|
||||
clearInterval(timer);
|
||||
timer = setInterval(tick, 25);
|
||||
tick();
|
||||
};
|
||||
|
||||
E.pause = function () {
|
||||
if (!E.playing) return;
|
||||
pausedPos = E.getPos();
|
||||
E.playing = false;
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
stopVoices();
|
||||
};
|
||||
|
||||
E.stop = function () {
|
||||
E.playing = false;
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
pausedPos = 1;
|
||||
stopVoices();
|
||||
};
|
||||
|
||||
E.seek = function (pos) {
|
||||
pausedPos = clamp(pos, 1, E.song.bars);
|
||||
if (E.playing) {
|
||||
stopVoices();
|
||||
transportStart = ctx.currentTime - (pausedPos - 1) / bps() + 0.04;
|
||||
resetScheduler();
|
||||
}
|
||||
};
|
||||
|
||||
E.getPos = function () {
|
||||
if (!E.playing) return pausedPos;
|
||||
const p = 1 + (ctx.currentTime - transportStart) * bps();
|
||||
const loop = E.song.bars;
|
||||
return ((p - 1) % loop + loop) % loop + 1;
|
||||
};
|
||||
|
||||
E.setBpm = function (bpm) {
|
||||
const pos = E.getPos();
|
||||
E.song.bpm = clamp(bpm, 40, 220);
|
||||
if (E.playing) {
|
||||
transportStart = ctx.currentTime - (pos - 1) / bps();
|
||||
resetScheduler();
|
||||
}
|
||||
pausedPos = pos;
|
||||
};
|
||||
|
||||
E.setSong = function (stones) {
|
||||
E.song.stones = (stones || []).slice();
|
||||
compile();
|
||||
if (E.playing) {
|
||||
transportStart = ctx.currentTime - (E.getPos() - 1) / bps();
|
||||
resetScheduler();
|
||||
}
|
||||
};
|
||||
|
||||
E.setWeather = function (w) {
|
||||
Object.assign(E.weather, w || {});
|
||||
applyClimate(G, E.weather);
|
||||
};
|
||||
|
||||
E.setRings = function (n) { E.rings = clamp(n, 0, 24); };
|
||||
|
||||
E.setVeinGain = function (i, v) {
|
||||
const g = G.veins[clamp(i, 0, 4)];
|
||||
if (g) g.gain.setTargetAtTime(clamp(v, 0, 1.4), ctx.currentTime, 0.03);
|
||||
};
|
||||
|
||||
E.setMuted = function (muted) {
|
||||
E.muted = !!muted;
|
||||
G.master.gain.setTargetAtTime(E.muted ? 0 : 0.85, ctx.currentTime, 0.08);
|
||||
};
|
||||
|
||||
E.getMuted = function () { return E.muted; };
|
||||
E.isPlaying = function () { return E.playing; };
|
||||
E.getBpm = function () { return E.song.bpm; };
|
||||
E.getEvents = function () { return events; };
|
||||
E.pausedPos = function () { return pausedPos; };
|
||||
|
||||
/* ————— analysis ————— */
|
||||
|
||||
E.strike = strike;
|
||||
|
||||
E.read = function () {
|
||||
G.analyser.getByteFrequencyData(freqData);
|
||||
G.analyser.getByteTimeDomainData(timeData);
|
||||
return { freq: freqData, time: timeData, sr: sr };
|
||||
};
|
||||
|
||||
/* ————— cores ————— */
|
||||
|
||||
E.byId = byId;
|
||||
E.rankFace = rankFace;
|
||||
E.refracture = function (coreId) {
|
||||
const core = byId(coreId);
|
||||
if (!core) return null;
|
||||
core.variant = (core.variant || 0) + 1;
|
||||
const met = makeFaces(core.buffer, core.variant);
|
||||
core.faces = met.faces;
|
||||
core.ranked = met.ranked;
|
||||
core.cuts = met.cuts;
|
||||
compile();
|
||||
if (E.playing) {
|
||||
transportStart = ctx.currentTime - (E.getPos() - 1) / bps();
|
||||
resetScheduler();
|
||||
}
|
||||
return core;
|
||||
};
|
||||
|
||||
E.newCore = function (opts) {
|
||||
opts = opts || {};
|
||||
const kinds = ['stair', 'rain', 'room', 'rail'];
|
||||
const kind = opts.kind || kinds[E.cores.length % kinds.length];
|
||||
return addCore({
|
||||
name: opts.name || kind + ' core ' + (E.cores.length + 1),
|
||||
seal: opts.seal || 'taken',
|
||||
kind: kind,
|
||||
seed: opts.seed || (Math.floor(Math.random() * 900) + 17),
|
||||
id: opts.id
|
||||
});
|
||||
};
|
||||
|
||||
E.addBufferCore = function (opts) {
|
||||
return addCore({
|
||||
name: opts.name,
|
||||
seal: opts.seal || 'taken',
|
||||
kind: 'import',
|
||||
buffer: opts.buffer,
|
||||
id: opts.id
|
||||
});
|
||||
};
|
||||
|
||||
E.onCores = function (fn) { E.listeners.cores.push(fn); };
|
||||
E.onLog = function (fn) { E.listeners.log.push(fn); };
|
||||
|
||||
E.addLog = function (tool, detail, seal, by) {
|
||||
E.log.unshift({ t: nowTime(), tool: tool, detail: detail, seal: seal || '', by: by || 'hand' });
|
||||
if (E.log.length > 40) E.log.pop();
|
||||
E.listeners.log.forEach(function (fn) { fn(E.log); });
|
||||
};
|
||||
|
||||
function nowTime() {
|
||||
const d = new Date();
|
||||
return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
|
||||
}
|
||||
|
||||
/* ————— import & record ————— */
|
||||
|
||||
E.importFile = function (file) {
|
||||
return file.arrayBuffer().then(function (ab) {
|
||||
return ctx.decodeAudioData(ab);
|
||||
}).then(function (buffer) {
|
||||
const name = file.name.replace(/\.[a-z0-9]+$/i, '').slice(0, 34) || 'imported';
|
||||
const core = addCore({ name: name, seal: 'taken', kind: 'import', buffer: buffer });
|
||||
E.addLog('cores.extract', name, 'taken', 'hand');
|
||||
return core;
|
||||
});
|
||||
};
|
||||
|
||||
E.recordRoom = function (seconds) {
|
||||
seconds = seconds || 6;
|
||||
return navigator.mediaDevices.getUserMedia({ audio: true }).then(function (stream) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
const types = ['audio/webm;codecs=opus', 'audio/webm', ''];
|
||||
let rec = null;
|
||||
for (let i = 0; i < types.length; i++) {
|
||||
try { rec = new MediaRecorder(stream, types[i] ? { mimeType: types[i] } : undefined); break; } catch (e) {}
|
||||
}
|
||||
if (!rec) { stream.getTracks().forEach(function (t) { t.stop(); }); reject(new Error('no recorder')); return; }
|
||||
const chunks = [];
|
||||
rec.ondataavailable = function (e) { if (e.data && e.data.size) chunks.push(e.data); };
|
||||
rec.onstop = function () {
|
||||
stream.getTracks().forEach(function (t) { t.stop(); });
|
||||
const blob = new Blob(chunks, { type: chunks[0] ? chunks[0].type : 'audio/webm' });
|
||||
blob.arrayBuffer().then(function (ab) { return ctx.decodeAudioData(ab); }).then(function (buffer) {
|
||||
const name = 'room, ' + nowTime();
|
||||
const core = addCore({ name: name, seal: 'taken', kind: 'import', buffer: buffer });
|
||||
E.addLog('cores.extract', name, 'taken', 'hand');
|
||||
resolve(core);
|
||||
}).catch(reject);
|
||||
};
|
||||
rec.start();
|
||||
setTimeout(function () { if (rec.state !== 'inactive') rec.stop(); }, seconds * 1000);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/* ————— render ————— */
|
||||
|
||||
function encodeWav(buffer) {
|
||||
const channels = Math.min(2, buffer.numberOfChannels);
|
||||
const len = buffer.length;
|
||||
const bytes = 44 + len * channels * 2;
|
||||
const ab = new ArrayBuffer(bytes);
|
||||
const view = new DataView(ab);
|
||||
function str(off, s) { for (let i = 0; i < s.length; i++) view.setUint8(off + i, s.charCodeAt(i)); }
|
||||
str(0, 'RIFF');
|
||||
view.setUint32(4, bytes - 8, true);
|
||||
str(8, 'WAVE');
|
||||
str(12, 'fmt ');
|
||||
view.setUint32(16, 16, true);
|
||||
view.setUint16(20, 1, true);
|
||||
view.setUint16(22, channels, true);
|
||||
view.setUint32(24, buffer.sampleRate, true);
|
||||
view.setUint32(28, buffer.sampleRate * channels * 2, true);
|
||||
view.setUint16(32, channels * 2, true);
|
||||
view.setUint16(34, 16, true);
|
||||
str(36, 'data');
|
||||
view.setUint32(40, len * channels * 2, true);
|
||||
const chans = [];
|
||||
for (let c = 0; c < channels; c++) chans.push(buffer.getChannelData(c));
|
||||
let off = 44;
|
||||
for (let i = 0; i < len; i++) {
|
||||
for (let c = 0; c < channels; c++) {
|
||||
const s = clamp(chans[c][i], -1, 1);
|
||||
view.setInt16(off, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
|
||||
off += 2;
|
||||
}
|
||||
}
|
||||
return new Blob([ab], { type: 'audio/wav' });
|
||||
}
|
||||
|
||||
E.render = function () {
|
||||
const duration = loopDur() + 2.4;
|
||||
const off = new OfflineAudioContext(2, Math.ceil(duration * sr), sr);
|
||||
const go = graph(off);
|
||||
go.ctx = off;
|
||||
Object.assign(go, { ctx: off });
|
||||
applyClimate(go, E.weather);
|
||||
go.tilt.gain.value = (macros.warmth - 0.5) * 10;
|
||||
go.hiss.gain.value = macros.dust * 0.03;
|
||||
go.limiter.threshold.value = -20 + macros.threshold * 20;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
go.veins[i].gain.value = G.veins[i].gain.value;
|
||||
}
|
||||
off.destination.channelCount = 2;
|
||||
const startAt = off.currentTime;
|
||||
events.forEach(function (ev) {
|
||||
const when = startAt + (ev.bar - 1) * barSec() + 0.01;
|
||||
const rate = Math.pow(2, (ev.pitch + ev.cycle * 0.01 * E.rings) / 12) * (1 + E.weather.heat * 0.03);
|
||||
const dur = Math.max(0.04, ev.face.len / rate);
|
||||
const src = off.createBufferSource();
|
||||
src.buffer = ev.core.buffer;
|
||||
src.playbackRate.value = rate;
|
||||
const g = off.createGain();
|
||||
const vol = clamp(ev.gain * (1 - Math.min(0.35, ev.cycle * 0.004 * E.rings)), 0, 2);
|
||||
g.gain.setValueAtTime(0, when);
|
||||
g.gain.linearRampToValueAtTime(vol, when + 0.004);
|
||||
g.gain.setValueAtTime(vol, Math.max(when + 0.004, when + dur - 0.03));
|
||||
g.gain.linearRampToValueAtTime(0, when + dur);
|
||||
src.connect(g);
|
||||
g.connect(go.veins[ev.vein] || go.veins[0]);
|
||||
src.start(when, clamp(ev.face.start, 0, ev.core.dur - 0.01));
|
||||
src.stop(when + dur + 0.01);
|
||||
});
|
||||
return off.startRendering().then(function (buf) {
|
||||
return encodeWav(buf);
|
||||
});
|
||||
};
|
||||
|
||||
E.download = function () {
|
||||
return E.render().then(function (blob) {
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = 'zhila-descent-04.wav';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(function () { URL.revokeObjectURL(a.href); }, 20000);
|
||||
return blob;
|
||||
});
|
||||
};
|
||||
|
||||
/* ————— default cores ————— */
|
||||
|
||||
addCore({ id: 'stairwell-03', name: 'stairwell, 3rd floor', seal: 'taken', kind: 'stair', seed: 3 });
|
||||
addCore({ id: 'rain-metal', name: 'rain on metal', seal: 'found', kind: 'rain', seed: 7 });
|
||||
addCore({ id: 'room-04', name: 'room tone 04', seal: 'gift', kind: 'room', seed: 11 });
|
||||
addCore({ id: 'rail-02', name: 'rail, night', seal: 'loan', kind: 'rail', seed: 5 });
|
||||
|
||||
E.log = [
|
||||
{ t: '21:52', tool: 'cores.extract', detail: 'stairwell, 3rd floor', seal: 'taken', by: 'hand' },
|
||||
{ t: '21:53', tool: 'faces.fracture', detail: 'stairwell-03 · 16 faces', seal: '', by: 'hand' },
|
||||
{ t: '21:55', tool: 'rings.grow', detail: 'ring 04 · young', seal: '', by: 'mcp' }
|
||||
];
|
||||
|
||||
window.ZHILA_ENGINE = E;
|
||||
})();
|
||||
@@ -0,0 +1,285 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>Zhila · descent 04</title>
|
||||
<meta name="description" content="Zhila — a DAW where sound is terrain. The choir above, your hands below, agents in the protocol.">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Cormorant:ital,wght@0,300;0,400;1,300;1,400&family=JetBrains+Mono:wght@200;300;400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%230c0908'/%3E%3Ccircle cx='16' cy='16' r='9' fill='none' stroke='%23e8432a' stroke-width='.8'/%3E%3Ccircle cx='16' cy='16' r='5.5' fill='%23000'/%3E%3C/svg%3E">
|
||||
</head>
|
||||
<body data-mode="choir" data-view="nave">
|
||||
|
||||
<div class="vignette" aria-hidden="true"></div>
|
||||
<div class="grain" aria-hidden="true"></div>
|
||||
<canvas id="terrain" aria-hidden="true"></canvas>
|
||||
<canvas id="breath" aria-hidden="true"></canvas>
|
||||
<div class="quarry-dim" aria-hidden="true"></div>
|
||||
|
||||
<div class="shell">
|
||||
|
||||
<header class="rail rail--top">
|
||||
<a class="brand" href="#" aria-label="Zhila, version 0.4">ZHILA<span class="brand-v">0.4</span></a>
|
||||
<div class="doc">
|
||||
<span class="doc-name">descent 04</span>
|
||||
<span class="doc-rule" aria-hidden="true"></span>
|
||||
<span class="doc-meta">A minor<i class="doc-dot"></i>4/4<i class="doc-dot"></i>5 veins</span>
|
||||
</div>
|
||||
<div class="views" role="group" aria-label="Room">
|
||||
<button class="view-btn is-on" type="button" data-view-btn="nave" aria-pressed="true">nave</button>
|
||||
<span class="mode-slash" aria-hidden="true">/</span>
|
||||
<button class="view-btn" type="button" data-view-btn="quarry" aria-pressed="false">quarry</button>
|
||||
</div>
|
||||
<div class="mode" role="group" aria-label="Who is playing">
|
||||
<button class="mode-btn is-on" type="button" data-mode-btn="choir" aria-pressed="true">choir</button>
|
||||
<span class="mode-slash" aria-hidden="true">/</span>
|
||||
<button class="mode-btn" type="button" data-mode-btn="hand" aria-pressed="false">hand</button>
|
||||
</div>
|
||||
<div class="net" id="net">
|
||||
<span class="led" aria-hidden="true"></span>
|
||||
<span class="net-tag">api</span>
|
||||
<i class="net-rule" aria-hidden="true"></i>
|
||||
<span id="net-text">connecting</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="sky" aria-label="The eye and the voice">
|
||||
<div class="sky-inner">
|
||||
<div class="void-wrap">
|
||||
<canvas id="halo" aria-hidden="true"></canvas>
|
||||
<button class="void" id="void" type="button" aria-label="Call the choir: make a layer from your words">
|
||||
<span class="void-word" id="void-word" aria-hidden="true">entry</span>
|
||||
<span class="void-label" aria-hidden="true">scene</span>
|
||||
<span class="void-hint" aria-hidden="true">call</span>
|
||||
</button>
|
||||
</div>
|
||||
<form class="invocation" id="invocation">
|
||||
<label class="sr" for="prompt">What should sound</label>
|
||||
<input id="prompt" name="prompt" type="text" placeholder="say what you want to hear" autocomplete="off" spellcheck="false">
|
||||
<button class="invoke" type="submit">call</button>
|
||||
</form>
|
||||
<p class="hint hint--choir"><kbd>⏎</kbd> call <span class="hint-rule" aria-hidden="true"></span> <kbd>space</kbd> play</p>
|
||||
<p class="hint hint--hand">drag cilia <span class="hint-rule" aria-hidden="true"></span> click the nave</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<main class="deck">
|
||||
|
||||
<aside class="agent rail" data-pool="0.95" aria-label="Choir — agent console">
|
||||
<div class="head">
|
||||
<h2 class="title">choir</h2>
|
||||
<span class="chips">
|
||||
<span class="chip" title="Embedded agent: a live model when the local server runs">api</span>
|
||||
<span class="chip" title="Model Context Protocol — the same tools for external agents (not wired yet)">mcp</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="state" id="agent-state">
|
||||
<span class="led" aria-hidden="true"></span>
|
||||
<span id="agent-state-text">listening</span>
|
||||
<span class="state-meta">tools</span>
|
||||
</div>
|
||||
<ol class="log" id="log" aria-live="polite"></ol>
|
||||
<div class="shape" aria-label="Shape of the next layer">
|
||||
<div class="shape-head"><span>shape</span><span>next</span></div>
|
||||
<label class="slider"><span class="slider-name">density</span><input type="range" min="0" max="100" value="62" id="s-density"><output for="s-density">0.62</output></label>
|
||||
<label class="slider"><span class="slider-name">depth</span><input type="range" min="0" max="100" value="78" id="s-depth"><output for="s-depth">0.78</output></label>
|
||||
<label class="slider"><span class="slider-name">grain</span><input type="range" min="0" max="100" value="31" id="s-grain"><output for="s-grain">0.31</output></label>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="corridor" data-pool="0.42" aria-label="Nave — the arrangement">
|
||||
<div class="head corridor-head">
|
||||
<h2 class="title">nave</h2>
|
||||
<span class="sub corridor-sub"><span>5 veins</span><i class="sub-rule"></i><span id="corridor-read">bar 7.3</span></span>
|
||||
</div>
|
||||
<div class="stage" id="stage">
|
||||
<canvas id="spires" aria-hidden="true"></canvas>
|
||||
<svg id="nave" role="img" aria-label="Five veins running toward the city at the end of the road"></svg>
|
||||
<span class="now-label" aria-hidden="true">now</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="side" aria-label="Hands">
|
||||
<section class="crystal rail" data-pool="0.95" aria-label="Crystal — notes">
|
||||
<div class="head">
|
||||
<h2 class="title">crystal</h2>
|
||||
<span class="sub">notes</span>
|
||||
</div>
|
||||
<div class="crystal-meta"><span id="crystal-bars">bars 5–8</span><span>A minor</span></div>
|
||||
<div class="lattice" id="lattice"></div>
|
||||
</section>
|
||||
<section class="cilia rail" data-pool="0.95" aria-label="Cilia — levels">
|
||||
<div class="head">
|
||||
<h2 class="title">cilia</h2>
|
||||
<span class="sub">levels</span>
|
||||
</div>
|
||||
<div class="faders" id="faders"></div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
</main>
|
||||
|
||||
<section class="quarry-tray" id="quarry-tray" aria-label="Core tray">
|
||||
<div class="head tray-head">
|
||||
<div class="tray-id">
|
||||
<h2 class="title">core tray</h2>
|
||||
<span class="sub" id="tray-name">stairwell, 3rd floor</span>
|
||||
</div>
|
||||
<div class="tray-meta">
|
||||
<span class="seal" id="tray-seal"><i aria-hidden="true"></i><span>taken</span></span>
|
||||
<i class="sub-rule" aria-hidden="true"></i>
|
||||
<span class="tray-num" id="tray-dur">12.4 s</span>
|
||||
<i class="sub-rule" aria-hidden="true"></i>
|
||||
<span class="tray-num" id="tray-pos">0.84 s</span>
|
||||
<i class="sub-rule" aria-hidden="true"></i>
|
||||
<button class="ghost ghost--small" id="fracture" type="button" title="Cut this core into sixteen faces">fracture</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tray-stage" id="tray-stage">
|
||||
<canvas id="tray" aria-hidden="true"></canvas>
|
||||
<svg id="tray-overlay" aria-hidden="true"></svg>
|
||||
<p class="tray-hint" aria-hidden="true">drag the lens to read the core</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<main class="quarry-deck" id="quarry-deck" aria-label="Quarry">
|
||||
|
||||
<aside class="store rail" aria-label="Core store">
|
||||
<div class="head">
|
||||
<h2 class="title">store</h2>
|
||||
<span class="sub">cores</span>
|
||||
</div>
|
||||
<div class="store-list" id="store"></div>
|
||||
<form class="choir-call" id="choir-call">
|
||||
<input id="choir-input" type="text" placeholder="send the choir for…" autocomplete="off" spellcheck="false">
|
||||
<button type="submit">call</button>
|
||||
</form>
|
||||
<button class="ghost ghost--small extract-btn" id="extract" type="button">extract by hand</button>
|
||||
</aside>
|
||||
|
||||
<section class="faces" aria-label="The face">
|
||||
<div class="head faces-head">
|
||||
<h2 class="title">the face</h2>
|
||||
<span class="sub" id="faces-read">16 faces</span>
|
||||
</div>
|
||||
<div class="face-wall" id="face-wall"></div>
|
||||
<div class="face-foot">
|
||||
<span class="face-status" id="face-status">strike a stone</span>
|
||||
<span class="place-group">
|
||||
<button class="ghost ghost--small" id="place" type="button" disabled>place</button>
|
||||
<button class="ghost ghost--small" id="place-ring" type="button" disabled>loop</button>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="weather rail" aria-label="Weather">
|
||||
<div class="head">
|
||||
<h2 class="title">weather</h2>
|
||||
<span class="sub">climate</span>
|
||||
</div>
|
||||
<div class="weather-list">
|
||||
<label class="slider"><span class="slider-name">age</span><input type="range" min="0" max="100" value="34" id="w-age"><output for="w-age">0.34</output></label>
|
||||
<label class="slider"><span class="slider-name">pressure</span><input type="range" min="0" max="100" value="52" id="w-pressure"><output for="w-pressure">0.52</output></label>
|
||||
<label class="slider"><span class="slider-name">air</span><input type="range" min="0" max="100" value="41" id="w-air"><output for="w-air">0.41</output></label>
|
||||
<label class="slider"><span class="slider-name">heat</span><input type="range" min="0" max="100" value="28" id="w-heat"><output for="w-heat">0.28</output></label>
|
||||
</div>
|
||||
<div class="climate" id="climate">young, damp, cold</div>
|
||||
|
||||
<div class="ring-block">
|
||||
<div class="ring-head"><span class="tp-label">ring</span><strong id="ring-count">04</strong></div>
|
||||
<div class="ring-visual" id="ring-visual"></div>
|
||||
<button class="ghost ghost--small" id="grow" type="button">grow ring</button>
|
||||
</div>
|
||||
|
||||
<div class="ghost-toggle">
|
||||
<span class="tp-label">ghost</span>
|
||||
<button class="toggle" id="ghost" type="button" aria-pressed="true">on</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="ledger" aria-label="Ledger">
|
||||
<div class="ledger-head">
|
||||
<span>ledger</span>
|
||||
<span class="ledger-sub">the memory of taking</span>
|
||||
</div>
|
||||
<ol class="ledger-list" id="ledger"></ol>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
<footer class="rail rail--transport" aria-label="Pulse">
|
||||
<input type="file" id="file-in" accept="audio/*" hidden>
|
||||
<div class="tp tp--go">
|
||||
<button class="play" id="play" type="button" aria-label="Play" aria-pressed="false">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path class="i-play" d="M8.5 5.2v13.6L19 12z"/><g class="i-pause"><path d="M9 5.5v13"/><path d="M15 5.5v13"/></g></svg>
|
||||
</button>
|
||||
<button class="icon" id="stop" type="button" aria-label="Stop">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="7" y="7" width="10" height="10"/></svg>
|
||||
</button>
|
||||
<button class="icon icon--rec" id="rec" type="button" aria-label="Record" aria-pressed="false">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4.2"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="tp tp--clock">
|
||||
<span class="tp-label">bar</span>
|
||||
<span class="tp-bars" id="bars">7.3</span>
|
||||
<span class="tp-time" id="time">00:12.66</span>
|
||||
</div>
|
||||
|
||||
<div class="tp tp--tempo">
|
||||
<span class="tp-label">tempo</span>
|
||||
<div class="tempo-stack">
|
||||
<button class="tp-num" id="bpm" type="button" aria-label="Tempo 128 — drag up or down">128.00</button>
|
||||
<span class="tp-sub">4/4<i class="tp-rule" aria-hidden="true"></i>A minor</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tp tp--knobs" aria-label="Nodes">
|
||||
<div class="knob" data-knob="warmth" data-value="0.58"><svg viewBox="0 0 34 34" aria-hidden="true"><circle class="k-track" cx="17" cy="17" r="13"/><path class="k-arc" d=""/><path class="k-tick" d=""/></svg><span class="knob-name">warmth</span></div>
|
||||
<div class="knob" data-knob="dust" data-value="0.34"><svg viewBox="0 0 34 34" aria-hidden="true"><circle class="k-track" cx="17" cy="17" r="13"/><path class="k-arc" d=""/><path class="k-tick" d=""/></svg><span class="knob-name">dust</span></div>
|
||||
<div class="knob" data-knob="threshold" data-value="0.72"><svg viewBox="0 0 34 34" aria-hidden="true"><circle class="k-track" cx="17" cy="17" r="13"/><path class="k-arc" d=""/><path class="k-tick" d=""/></svg><span class="knob-name">threshold</span></div>
|
||||
</div>
|
||||
|
||||
<div class="tp tp--in" role="group" aria-label="What the instrument listens to">
|
||||
<span class="tp-label">extract</span>
|
||||
<div class="srcs">
|
||||
<button class="src is-on" type="button" data-src="drone" aria-pressed="true" title="Generate a core from the instrument">core</button>
|
||||
<button class="src" type="button" data-src="file" aria-pressed="false" title="Import an audio file as a core">file</button>
|
||||
<button class="src" type="button" data-src="room" aria-pressed="false" title="Record the room into a core">room</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tp tp--scope">
|
||||
<span class="tp-label">signal</span>
|
||||
<canvas id="scope" aria-hidden="true"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="tp tp--master">
|
||||
<div class="master-top">
|
||||
<span class="tp-label">master</span>
|
||||
<span class="tp-db" id="master-db">−∞ dB</span>
|
||||
<button class="snd" id="snd" type="button" aria-pressed="true" aria-label="Audio on">audio</button>
|
||||
</div>
|
||||
<div class="meter" aria-hidden="true"><i class="meter-fill" id="meter-fill"></i><b class="meter-peak" id="meter-peak"></b></div>
|
||||
</div>
|
||||
|
||||
<div class="tp tp--end">
|
||||
<button class="ghost" type="button">layer</button>
|
||||
<button class="ghost ghost--ember" type="button">render</button>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
|
||||
<script src="terrain.js"></script>
|
||||
<script src="engine.js"></script>
|
||||
<script src="app.js"></script>
|
||||
<script src="quarry.js"></script>
|
||||
<script src="agent.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 468 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 422 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
@@ -0,0 +1,956 @@
|
||||
/* ZHILA · the quarry
|
||||
a core is a place, a face is a cut, a ring is a year.
|
||||
the choir goes out and takes; the hand cuts and sets. */
|
||||
|
||||
(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 esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||
|
||||
const tray = $('#tray');
|
||||
const tctx = tray.getContext('2d');
|
||||
const overlay = $('#tray-overlay');
|
||||
const stage = $('#tray-stage');
|
||||
const wall = $('#face-wall');
|
||||
const storeEl = $('#store');
|
||||
const ledgerEl = $('#ledger');
|
||||
|
||||
const E = window.ZHILA_ENGINE;
|
||||
const cores = E ? E.cores : [];
|
||||
const SEMIS = [-12, -9, -7, -5, 0, 2, 3, 5, 7, 8, 10, 12, 14, 15, 17, 19];
|
||||
|
||||
const state = {
|
||||
sel: 0,
|
||||
lens: 0.84,
|
||||
fracture: 0,
|
||||
faceSeed: 7,
|
||||
selFace: -1,
|
||||
rings: 4,
|
||||
ghost: true,
|
||||
weather: { age: 0.34, pressure: 0.52, air: 0.41, heat: 0.28 }
|
||||
};
|
||||
|
||||
const HAND_BANK = ['underpass, 2am', 'wet brick', 'empty pool', 'ventilation shaft', 'far room', 'snow on tin', 'lift cable'];
|
||||
let handBank = HAND_BANK.slice();
|
||||
let lensLine = null, lensHandle = null, lensText = null;
|
||||
|
||||
/* ————— cores ————— */
|
||||
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
||||
/* ————— ledger ————— */
|
||||
|
||||
let entries = E ? E.log : [];
|
||||
|
||||
function sealHTML(kind, small) {
|
||||
if (!kind) return '';
|
||||
return '<span class="seal seal--' + kind + '"><i aria-hidden="true"></i><span>' + kind + '</span></span>';
|
||||
}
|
||||
|
||||
function renderLedger() {
|
||||
entries = E ? E.log : entries;
|
||||
ledgerEl.innerHTML = entries.slice(0, 3).map(function (e) {
|
||||
return '<li class="ledger-item">' +
|
||||
'<div class="ledger-top"><span class="ledger-t">' + e.t + '</span>' +
|
||||
'<span class="ledger-tool">' + esc(e.tool) + '</span>' +
|
||||
'<span class="ledger-by' + (e.by === 'mcp' ? ' is-mcp' : '') + '">' + e.by + '</span></div>' +
|
||||
'<div class="ledger-detail">' + esc(e.detail) + ' ' + sealHTML(e.seal) + '</div>' +
|
||||
'</li>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function nowTime() {
|
||||
const d = new Date();
|
||||
return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
|
||||
}
|
||||
|
||||
function log(tool, detail, seal, by) {
|
||||
if (E) E.addLog(tool, detail, seal, by);
|
||||
}
|
||||
|
||||
/* ————— core store ————— */
|
||||
|
||||
function drawThumb(canvas, core) {
|
||||
const g = canvas.getContext('2d');
|
||||
const W = canvas.width, H = canvas.height;
|
||||
g.clearRect(0, 0, W, H);
|
||||
const x = W * 0.18, y = H * 0.04, w = W * 0.64, h = H * 0.92, r = w / 2;
|
||||
const p = new Path2D();
|
||||
p.moveTo(x + r, y);
|
||||
p.lineTo(x + w - r, y);
|
||||
p.arc(x + w - r, y + r, r, -Math.PI / 2, Math.PI / 2);
|
||||
p.lineTo(x + r, y + h);
|
||||
p.arc(x + r, y + h - r, r, Math.PI / 2, Math.PI * 1.5);
|
||||
p.closePath();
|
||||
|
||||
g.save();
|
||||
g.clip(p);
|
||||
const grd = g.createLinearGradient(x, y, x + w, y);
|
||||
grd.addColorStop(0, '#1a0d0a');
|
||||
grd.addColorStop(0.45, '#2b1611');
|
||||
grd.addColorStop(1, '#0b0605');
|
||||
g.fillStyle = grd;
|
||||
g.fillRect(x, y, w, h);
|
||||
|
||||
const rnd = mulberry(core.seed * 31 + 3);
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const yy = y + h * ((i + 0.6) / 8.6);
|
||||
const amp = 1 + rnd() * 3;
|
||||
g.beginPath();
|
||||
for (let k = 0; k <= 10; k++) {
|
||||
const u = k / 10;
|
||||
const px = x + u * w;
|
||||
const py = yy + Math.sin(u * 6 + i) * amp;
|
||||
if (k === 0) g.moveTo(px, py); else g.lineTo(px, py);
|
||||
}
|
||||
g.strokeStyle = i % 2 ? 'rgba(190,130,105,.16)' : 'rgba(80,45,35,.5)';
|
||||
g.lineWidth = 1;
|
||||
g.stroke();
|
||||
}
|
||||
// the seam of sound
|
||||
g.beginPath();
|
||||
for (let i = 0; i < core.env.length; i++) {
|
||||
const px = x + (i / (core.env.length - 1)) * w;
|
||||
const py = y + h / 2 - core.env[i] * h * 0.3;
|
||||
if (i === 0) g.moveTo(px, py); else g.lineTo(px, py);
|
||||
}
|
||||
g.strokeStyle = 'rgba(255,105,75,.55)';
|
||||
g.lineWidth = 1;
|
||||
g.stroke();
|
||||
g.restore();
|
||||
|
||||
g.strokeStyle = 'rgba(239,228,212,.16)';
|
||||
g.lineWidth = 1;
|
||||
g.stroke(p);
|
||||
g.beginPath();
|
||||
g.ellipse(x + r, y + h / 2, r * 0.55, h * 0.3, 0, 0, TAU);
|
||||
g.strokeStyle = 'rgba(239,228,212,.1)';
|
||||
g.stroke();
|
||||
}
|
||||
|
||||
function buildStore() {
|
||||
storeEl.innerHTML = '';
|
||||
cores.forEach(function (core, i) {
|
||||
const item = document.createElement('button');
|
||||
item.type = 'button';
|
||||
item.className = 'core-item' + (i === state.sel ? ' is-on' : '');
|
||||
item.dataset.core = i;
|
||||
item.innerHTML =
|
||||
'<canvas class="core-thumb" width="96" height="116" aria-hidden="true"></canvas>' +
|
||||
'<span class="core-body">' +
|
||||
'<span class="core-name">' + esc(core.name) + '</span>' +
|
||||
'<span class="core-meta">' + core.dur.toFixed(1) + ' s ' + sealHTML(core.seal) + '</span>' +
|
||||
'</span>';
|
||||
storeEl.appendChild(item);
|
||||
drawThumb(item.querySelector('canvas'), core);
|
||||
});
|
||||
}
|
||||
|
||||
storeEl.addEventListener('click', function (e) {
|
||||
const item = e.target.closest('.core-item');
|
||||
if (!item) return;
|
||||
selectCore(+item.dataset.core);
|
||||
});
|
||||
|
||||
function highlightStore() {
|
||||
$$('.core-item', storeEl).forEach(function (el, i) {
|
||||
const on = i === state.sel;
|
||||
el.classList.toggle('is-on', on);
|
||||
if (on && el.scrollIntoView) el.scrollIntoView({ block: 'nearest' });
|
||||
});
|
||||
}
|
||||
|
||||
/* ————— tray ————— */
|
||||
|
||||
function trayGeom() {
|
||||
const W = stage.clientWidth, H = stage.clientHeight;
|
||||
const coreH = clamp(H * 0.55, 96, 172);
|
||||
const top = Math.max(36, (H - coreH) / 2 - 10);
|
||||
const pad = 24;
|
||||
return { W: W, H: H, x0: pad + 10, x1: W - pad - 10, top: top, bottom: top + coreH, coreH: coreH };
|
||||
}
|
||||
|
||||
function capsule(g, x0, top, x1, bottom) {
|
||||
const p = new Path2D();
|
||||
if (p.roundRect) p.roundRect(x0, top, x1 - x0, bottom - top, 2.5);
|
||||
else p.rect(x0, top, x1 - x0, bottom - top);
|
||||
return p;
|
||||
}
|
||||
|
||||
function drawTray() {
|
||||
const g0 = trayGeom();
|
||||
if (!g0.W || !g0.H) return;
|
||||
const dpr = Math.min(2, window.devicePixelRatio || 1);
|
||||
tray.width = Math.round(g0.W * dpr);
|
||||
tray.height = Math.round(g0.H * dpr);
|
||||
tctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
tctx.clearRect(0, 0, g0.W, g0.H);
|
||||
|
||||
const core = cores[state.sel];
|
||||
const { x0, x1, top, bottom, coreH } = g0;
|
||||
const mid = (top + bottom) / 2;
|
||||
const path = capsule(tctx, x0, top, x1, bottom);
|
||||
const rnd = mulberry(core.seed * 131 + 7);
|
||||
|
||||
// shadow under the core
|
||||
tctx.save();
|
||||
tctx.filter = 'blur(14px)';
|
||||
tctx.fillStyle = 'rgba(0,0,0,.75)';
|
||||
tctx.beginPath();
|
||||
tctx.ellipse((x0 + x1) / 2, bottom + 10, (x1 - x0) * 0.48, coreH * 0.22, 0, 0, TAU);
|
||||
tctx.fill();
|
||||
tctx.restore();
|
||||
|
||||
tctx.save();
|
||||
tctx.clip(path);
|
||||
const grd = tctx.createLinearGradient(0, top, 0, bottom);
|
||||
grd.addColorStop(0, '#26130d');
|
||||
grd.addColorStop(0.16, '#170b08');
|
||||
grd.addColorStop(0.55, '#100705');
|
||||
grd.addColorStop(1, '#040202');
|
||||
tctx.fillStyle = grd;
|
||||
tctx.fillRect(x0, top, x1 - x0, coreH);
|
||||
|
||||
// strata: sediment layers
|
||||
const bands = 10;
|
||||
for (let i = 0; i < bands; i++) {
|
||||
const yy = top + coreH * ((i + 0.55) / (bands + 0.9)) + (rnd() - 0.5) * 4;
|
||||
const amp = 2 + rnd() * 7;
|
||||
const freq = 1.2 + rnd() * 2.4;
|
||||
const phase = rnd() * TAU;
|
||||
const pts = [];
|
||||
for (let k = 0; k <= 32; k++) {
|
||||
const u = k / 32;
|
||||
const px = x0 + u * (x1 - x0);
|
||||
const py = yy + Math.sin(u * TAU * freq + phase) * amp + Math.sin(u * TAU * 5.3 + phase * 2) * 1.4;
|
||||
pts.push([px, py]);
|
||||
}
|
||||
tctx.beginPath();
|
||||
pts.forEach(function (p, k) { if (k === 0) tctx.moveTo(p[0], p[1]); else tctx.lineTo(p[0], p[1]); });
|
||||
tctx.lineTo(x1, bottom);
|
||||
tctx.lineTo(x0, bottom);
|
||||
tctx.closePath();
|
||||
tctx.fillStyle = i % 2 ? 'rgba(150,60,40,.05)' : 'rgba(0,0,0,.18)';
|
||||
tctx.fill();
|
||||
tctx.beginPath();
|
||||
pts.forEach(function (p, k) { if (k === 0) tctx.moveTo(p[0], p[1]); else tctx.lineTo(p[0], p[1]); });
|
||||
tctx.strokeStyle = i % 3 === 0 ? 'rgba(232,64,42,.16)' : 'rgba(190,130,105,.1)';
|
||||
tctx.lineWidth = 0.8;
|
||||
tctx.stroke();
|
||||
}
|
||||
|
||||
// the seam of sound, inside the stone
|
||||
const env = core.env;
|
||||
const ampY = coreH * 0.3;
|
||||
if (state.ghost) {
|
||||
tctx.beginPath();
|
||||
for (let i = 0; i < env.length; i++) {
|
||||
const px = x0 + (i / (env.length - 1)) * (x1 - x0);
|
||||
const py = mid - clamp(env[i] * 1.45, 0, 1) * ampY * 1.25;
|
||||
if (i === 0) tctx.moveTo(px, py); else tctx.lineTo(px, py);
|
||||
}
|
||||
tctx.strokeStyle = 'rgba(232,64,42,.14)';
|
||||
tctx.lineWidth = 0.9;
|
||||
tctx.stroke();
|
||||
}
|
||||
tctx.beginPath();
|
||||
for (let i = 0; i < env.length; i++) {
|
||||
const px = x0 + (i / (env.length - 1)) * (x1 - x0);
|
||||
const py = mid - env[i] * ampY;
|
||||
if (i === 0) tctx.moveTo(px, py); else tctx.lineTo(px, py);
|
||||
}
|
||||
for (let i = env.length - 1; i >= 0; i--) {
|
||||
const px = x0 + (i / (env.length - 1)) * (x1 - x0);
|
||||
tctx.lineTo(px, mid + env[i] * ampY * 0.78);
|
||||
}
|
||||
tctx.closePath();
|
||||
tctx.fillStyle = 'rgba(232,64,42,.13)';
|
||||
tctx.fill();
|
||||
tctx.save();
|
||||
tctx.shadowColor = 'rgba(232,64,42,.75)';
|
||||
tctx.shadowBlur = 14;
|
||||
tctx.beginPath();
|
||||
for (let i = 0; i < env.length; i++) {
|
||||
const px = x0 + (i / (env.length - 1)) * (x1 - x0);
|
||||
const py = mid - env[i] * ampY;
|
||||
if (i === 0) tctx.moveTo(px, py); else tctx.lineTo(px, py);
|
||||
}
|
||||
tctx.strokeStyle = 'rgba(255,140,105,.95)';
|
||||
tctx.lineWidth = 1.5;
|
||||
tctx.stroke();
|
||||
tctx.restore();
|
||||
|
||||
// pits and hairlines in the stone
|
||||
for (let k = 0; k < 16; k++) {
|
||||
const px = x0 + rnd() * (x1 - x0);
|
||||
const py = top + rnd() * coreH;
|
||||
const rr = 0.7 + rnd() * 2.2;
|
||||
tctx.beginPath();
|
||||
tctx.ellipse(px, py, rr * 1.9, rr, 0, 0, TAU);
|
||||
tctx.fillStyle = 'rgba(0,0,0,.4)';
|
||||
tctx.fill();
|
||||
tctx.beginPath();
|
||||
tctx.ellipse(px, py + 0.7, rr * 1.5, rr * 0.55, 0, 0, TAU);
|
||||
tctx.strokeStyle = 'rgba(232,64,42,.16)';
|
||||
tctx.lineWidth = 0.6;
|
||||
tctx.stroke();
|
||||
}
|
||||
for (let k = 0; k < 3; k++) {
|
||||
let cx = x0 + (0.12 + rnd() * 0.74) * (x1 - x0);
|
||||
let cy = top + rnd() * coreH * 0.55;
|
||||
tctx.beginPath();
|
||||
tctx.moveTo(cx, cy);
|
||||
for (let s2 = 0; s2 < 6; s2++) {
|
||||
cx += (rnd() - 0.5) * 12;
|
||||
cy += 4 + rnd() * 7;
|
||||
tctx.lineTo(cx, cy);
|
||||
}
|
||||
tctx.strokeStyle = 'rgba(0,0,0,.55)';
|
||||
tctx.lineWidth = 1.2;
|
||||
tctx.stroke();
|
||||
tctx.strokeStyle = 'rgba(232,64,42,.15)';
|
||||
tctx.lineWidth = 0.5;
|
||||
tctx.stroke();
|
||||
}
|
||||
|
||||
// fractures, gashes in the core
|
||||
core.cuts.forEach(function (cut) {
|
||||
const x = x0 + clamp(cut / core.dur, 0, 1) * (x1 - x0);
|
||||
const jag = mulberry(Math.round(cut * 100) + core.seed);
|
||||
const pts = [];
|
||||
for (let y = top; y <= bottom; y += 7) pts.push([x + (jag() - 0.5) * 3.2, y]);
|
||||
tctx.beginPath();
|
||||
pts.forEach(function (p, k) { if (k === 0) tctx.moveTo(p[0], p[1]); else tctx.lineTo(p[0], p[1]); });
|
||||
tctx.strokeStyle = 'rgba(0,0,0,.8)';
|
||||
tctx.lineWidth = 5;
|
||||
tctx.stroke();
|
||||
tctx.strokeStyle = 'rgba(255,110,80,.62)';
|
||||
tctx.lineWidth = 1.15;
|
||||
tctx.stroke();
|
||||
});
|
||||
|
||||
// surface light
|
||||
const hi = tctx.createLinearGradient(0, top, 0, top + coreH * 0.3);
|
||||
hi.addColorStop(0, 'rgba(255,205,175,.1)');
|
||||
hi.addColorStop(1, 'rgba(255,205,175,0)');
|
||||
tctx.fillStyle = hi;
|
||||
tctx.fillRect(x0, top, x1 - x0, coreH * 0.3);
|
||||
for (let k = 0; k < 6; k++) {
|
||||
const cx = x0 + 8 + rnd() * (x1 - x0 - 16);
|
||||
const w = 3 + rnd() * 11;
|
||||
const d = 1 + rnd() * 3.4;
|
||||
tctx.beginPath();
|
||||
tctx.moveTo(cx - w / 2, top);
|
||||
tctx.lineTo(cx + w / 2, top);
|
||||
tctx.lineTo(cx + (rnd() - 0.5) * 4, top + d);
|
||||
tctx.closePath();
|
||||
tctx.fillStyle = 'rgba(2,1,1,.75)';
|
||||
tctx.fill();
|
||||
}
|
||||
tctx.restore();
|
||||
|
||||
tctx.strokeStyle = 'rgba(239,228,212,.2)';
|
||||
tctx.lineWidth = 1;
|
||||
tctx.stroke(path);
|
||||
|
||||
// the cut ends of the bore
|
||||
[x0 + 6, x1 - 6].forEach(function (cx) {
|
||||
tctx.beginPath();
|
||||
tctx.ellipse(cx, mid, 6, coreH / 2 - 1, 0, 0, TAU);
|
||||
tctx.fillStyle = 'rgba(3,2,2,.55)';
|
||||
tctx.fill();
|
||||
tctx.strokeStyle = 'rgba(239,228,212,.2)';
|
||||
tctx.lineWidth = 0.9;
|
||||
tctx.stroke();
|
||||
tctx.beginPath();
|
||||
tctx.ellipse(cx, mid, 2.4, coreH * 0.34, 0, 0, TAU);
|
||||
tctx.strokeStyle = 'rgba(239,228,212,.08)';
|
||||
tctx.lineWidth = 0.7;
|
||||
tctx.stroke();
|
||||
});
|
||||
}
|
||||
|
||||
function buildOverlay() {
|
||||
const g = trayGeom();
|
||||
if (!g.W || !g.H) return;
|
||||
const core = cores[state.sel];
|
||||
const { x0, x1, top, bottom } = g;
|
||||
let s = '';
|
||||
s += '<line x1="' + x0 + '" x2="' + x1 + '" y1="' + (top - 20) + '" y2="' + (top - 20) + '" stroke="rgba(232,64,42,.14)" stroke-width=".7"/>';
|
||||
|
||||
core.cuts.forEach(function (cut, i) {
|
||||
const x = x0 + clamp(cut / core.dur, 0, 1) * (x1 - x0);
|
||||
s += '<line class="tray-cut" x1="' + x.toFixed(1) + '" x2="' + x.toFixed(1) + '" y1="' + (top - 14) + '" y2="' + (bottom + 12) + '"/>';
|
||||
s += '<text class="tray-cut-text" x="' + (x + 4).toFixed(1) + '" y="' + (top - 6) + '">0' + (i + 1) + '</text>';
|
||||
});
|
||||
|
||||
const ry = bottom + 30;
|
||||
s += '<line x1="' + x0 + '" x2="' + x1 + '" y1="' + ry + '" y2="' + ry + '" stroke="rgba(155,133,121,.22)" stroke-width=".7"/>';
|
||||
const step = core.dur > 16 ? 4 : core.dur > 10 ? 2 : 1;
|
||||
for (let t = 0; t <= core.dur + 0.01; t += step) {
|
||||
const x = x0 + clamp(t / core.dur, 0, 1) * (x1 - x0);
|
||||
const major = t % (step * 2) === 0;
|
||||
s += '<line class="tray-tick' + (major ? ' tray-tick--major' : '') + '" x1="' + x.toFixed(1) + '" x2="' + x.toFixed(1) + '" y1="' + ry + '" y2="' + (ry + (major ? 6 : 3)) + '"/>';
|
||||
if (major) {
|
||||
s += '<text class="tray-tick-text" x="' + (x + 3).toFixed(1) + '" y="' + (ry + 15) + '">' + t.toFixed(0) + 's</text>';
|
||||
}
|
||||
}
|
||||
|
||||
s += '<g id="tray-lens-g">' +
|
||||
'<line class="tray-lens"/>' +
|
||||
'<rect class="tray-lens-handle" width="4" height="12" rx="0"/>' +
|
||||
'<text class="tray-lens-text"></text>' +
|
||||
'</g>';
|
||||
overlay.setAttribute('viewBox', '0 0 ' + g.W + ' ' + g.H);
|
||||
overlay.innerHTML = s;
|
||||
lensLine = overlay.querySelector('.tray-lens');
|
||||
lensHandle = overlay.querySelector('.tray-lens-handle');
|
||||
lensText = overlay.querySelector('.tray-lens-text');
|
||||
updateLensOnly();
|
||||
}
|
||||
|
||||
function updateLensOnly() {
|
||||
if (!lensLine || !lensText) return;
|
||||
const g = trayGeom();
|
||||
if (!g.W) return;
|
||||
const core = cores[state.sel];
|
||||
const lx = g.x0 + clamp(state.lens / core.dur, 0, 1) * (g.x1 - g.x0);
|
||||
lensLine.setAttribute('x1', lx.toFixed(1));
|
||||
lensLine.setAttribute('x2', lx.toFixed(1));
|
||||
lensLine.setAttribute('y1', (g.top - 16).toFixed(1));
|
||||
lensLine.setAttribute('y2', (g.bottom + 16).toFixed(1));
|
||||
lensHandle.setAttribute('x', (lx - 2).toFixed(1));
|
||||
lensHandle.setAttribute('y', (g.top - 24).toFixed(1));
|
||||
lensText.setAttribute('x', Math.min(lx + 8, g.x1 - 36).toFixed(1));
|
||||
lensText.setAttribute('y', (g.top - 12).toFixed(1));
|
||||
lensText.textContent = state.lens.toFixed(2) + 's';
|
||||
const pos = $('#tray-pos');
|
||||
if (pos) pos.textContent = state.lens.toFixed(2) + ' s';
|
||||
}
|
||||
|
||||
/* ————— faces ————— */
|
||||
|
||||
function stoneShape(cell, rnd) {
|
||||
const inset = 1.4;
|
||||
const x0 = cell.x + inset, y0 = cell.y + inset;
|
||||
const x1 = cell.x + cell.w - inset, y1 = cell.y + cell.h - inset;
|
||||
const corners = [[x0, y0], [x1, y0], [x1, y1], [x0, y1]];
|
||||
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.5, a[1] + (rnd() - 0.5) * 2.5]);
|
||||
pts.push([
|
||||
(a[0] + b[0]) / 2 + (rnd() - 0.5) * 9,
|
||||
(a[1] + b[1]) / 2 + (rnd() - 0.5) * 9
|
||||
]);
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
function centroid(pts) {
|
||||
const n = pts.length;
|
||||
return [pts.reduce((a, p) => a + p[0], 0) / n, pts.reduce((a, p) => a + p[1], 0) / n];
|
||||
}
|
||||
|
||||
function ptsStr(pts) {
|
||||
return pts.map(function (p) { return p[0].toFixed(1) + ',' + p[1].toFixed(1); }).join(' ');
|
||||
}
|
||||
|
||||
function sealMark(x, y, kind) {
|
||||
let inner = '';
|
||||
if (kind === 'taken') inner = '<path d="M' + (x - 1.4) + ' ' + (y - 1.4) + 'L' + (x + 1.4) + ' ' + (y + 1.4) + 'M' + (x + 1.4) + ' ' + (y - 1.4) + 'L' + (x - 1.4) + ' ' + (y + 1.4) + '" stroke="rgba(232,64,42,.85)" stroke-width=".8" fill="none"/>';
|
||||
else if (kind === 'found') inner = '<circle cx="' + x + '" cy="' + y + '" r="1.1" class="stone-seal-dot"/>';
|
||||
else if (kind === 'loan') inner = '<path d="M' + (x - 1.3) + ' ' + (y + 1.3) + 'L' + (x + 1.3) + ' ' + (y - 1.3) + '" stroke="rgba(232,64,42,.85)" stroke-width=".8"/>';
|
||||
else if (kind === 'gift') inner = '<circle cx="' + x + '" cy="' + y + '" r=".9" fill="none" stroke="rgba(239,228,212,.7)" stroke-width=".7"/>';
|
||||
return '<circle class="stone-seal" cx="' + x + '" cy="' + y + '" r="3"/>' + inner;
|
||||
}
|
||||
|
||||
function buildFaces(seed) {
|
||||
if (!wall || !wall.clientWidth || !wall.clientHeight) return;
|
||||
state.faceSeed = seed;
|
||||
const W = wall.clientWidth, H = wall.clientHeight;
|
||||
const core = cores[state.sel];
|
||||
const rnd = mulberry(seed);
|
||||
const rows = 4, gap = 8;
|
||||
let hs = [0, 0, 0, 0].map(function () { return 0.78 + rnd() * 0.44; });
|
||||
const hsum = hs.reduce(function (a, b) { return a + b; }, 0);
|
||||
const availH = H - gap * (rows - 1) - 2;
|
||||
let y = 1;
|
||||
const rowRects = [];
|
||||
hs.forEach(function (h) {
|
||||
const hh = availH * h / hsum;
|
||||
rowRects.push({ y: y, h: hh });
|
||||
y += hh + gap;
|
||||
});
|
||||
|
||||
const faces = [];
|
||||
let idx = 0;
|
||||
let svg = '<svg viewBox="0 0 ' + W + ' ' + H + '" preserveAspectRatio="none"><defs>';
|
||||
['A', 'B', 'C', 'D'].forEach(function (t, ti) {
|
||||
svg += '<linearGradient id="stoneGrad' + t + '" x1="0" y1="0" x2="0" y2="1">' +
|
||||
'<stop offset="0" stop-color="#' + ['2b1811', '22120d', '31201a', '1a0e0a'][ti] + '"/>' +
|
||||
'<stop offset=".3" stop-color="#' + ['160c08', '120906', '180d09', '0e0705'][ti] + '"/>' +
|
||||
'<stop offset="1" stop-color="#040202"/>' +
|
||||
'</linearGradient>';
|
||||
});
|
||||
svg += '</defs>';
|
||||
rowRects.forEach(function (row, r) {
|
||||
let ws = [0, 0, 0, 0].map(function () { return 0.72 + rnd() * 0.56; });
|
||||
const wsum = ws.reduce(function (a, b) { return a + b; }, 0);
|
||||
const availW = W - gap * 3 - 2;
|
||||
let x = 1;
|
||||
ws.forEach(function (w, c) {
|
||||
const ww = availW * w / wsum;
|
||||
const cell = { x: x, y: row.y, w: ww, h: row.h };
|
||||
const pts = stoneShape(cell, rnd);
|
||||
const cen = centroid(pts);
|
||||
const inner = pts.map(function (p) { return [p[0] + (cen[0] - p[0]) * 0.09, p[1] + (cen[1] - p[1]) * 0.09]; });
|
||||
const inner2 = pts.map(function (p) { return [p[0] + (cen[0] - p[0]) * 0.2, p[1] + (cen[1] - p[1]) * 0.2]; });
|
||||
const tone = ['a', 'b', 'c', 'd'][Math.floor(rnd() * 4)];
|
||||
const lip = [pts[7], pts[0], pts[1], pts[2]];
|
||||
const foot = [pts[3], pts[4], pts[5], pts[6]];
|
||||
const grain = [];
|
||||
const gy = cell.y + cell.h * 0.58;
|
||||
for (let k = 0; k <= 8; k++) {
|
||||
const u = k / 8;
|
||||
grain.push([cell.x + 7 + u * (cell.w - 14), gy + Math.sin(u * 5 + rnd() * 2) * 3.4]);
|
||||
}
|
||||
const grain2 = [];
|
||||
const gy2 = cell.y + cell.h * 0.79;
|
||||
for (let k = 0; k <= 6; k++) {
|
||||
const u = k / 6;
|
||||
grain2.push([cell.x + 11 + u * (cell.w - 22), gy2 + Math.sin(u * 4 + rnd() * 2) * 2.6]);
|
||||
}
|
||||
const crack = [];
|
||||
let cx = cell.x + cell.w * (0.45 + rnd() * 0.3), cy = cell.y + 6;
|
||||
crack.push([cx, cy]);
|
||||
for (let k = 0; k < 4; k++) {
|
||||
cx += (rnd() - 0.5) * 12;
|
||||
cy += 4 + rnd() * 8;
|
||||
crack.push([cx, cy]);
|
||||
}
|
||||
const pits = [0, 1, 2].map(function () {
|
||||
return [cell.x + 12 + rnd() * (cell.w - 24), cell.y + 12 + rnd() * (cell.h - 24), 0.7 + rnd() * 1.3];
|
||||
});
|
||||
const strata = [];
|
||||
for (let k = 0; k <= 6; k++) {
|
||||
const u = k / 6;
|
||||
strata.push([cell.x + 8 + u * (cell.w - 16), cell.y + cell.h * 0.32 + Math.sin(u * 4 + r) * 2.4]);
|
||||
}
|
||||
const num = String(idx + 1).padStart(2, '0');
|
||||
const slice = core.faces[idx];
|
||||
const face = {
|
||||
i: idx,
|
||||
semis: SEMIS[(3 - r) * 4 + c],
|
||||
seal: core.seal,
|
||||
t0: slice ? +slice.start.toFixed(2) : 0,
|
||||
t1: slice ? +(slice.start + slice.len).toFixed(2) : 0
|
||||
};
|
||||
faces.push(face);
|
||||
const isSel = state.selFace === idx;
|
||||
svg += '<g class="stone' + (isSel ? ' is-sel' : '') + '" data-i="' + idx + '">' +
|
||||
'<circle class="stone-flash" cx="' + (cen[0]).toFixed(1) + '" cy="' + (cen[1]).toFixed(1) + '" r="' + (Math.min(cell.w, cell.h) * 0.42).toFixed(1) + '"/>' +
|
||||
'<polygon class="stone-body stone-body--' + tone + '" points="' + ptsStr(pts) + '"/>' +
|
||||
'<polyline class="stone-lip" points="' + ptsStr(lip) + '"/>' +
|
||||
'<polyline class="stone-foot-lip" points="' + ptsStr(foot) + '"/>' +
|
||||
'<polygon class="stone-face" points="' + ptsStr(inner) + '"/>' +
|
||||
'<polygon class="stone-face stone-face--inner" points="' + ptsStr(inner2) + '"/>' +
|
||||
'<polyline class="stone-strata" points="' + ptsStr(strata) + '"/>' +
|
||||
'<polyline class="stone-grain" points="' + ptsStr(grain) + '"/>' +
|
||||
'<polyline class="stone-grain stone-grain--faint" points="' + ptsStr(grain2) + '"/>' +
|
||||
'<polyline class="stone-crack" points="' + ptsStr(crack) + '"/>' +
|
||||
pits.map(function (p) {
|
||||
return '<ellipse class="stone-pit" cx="' + p[0].toFixed(1) + '" cy="' + p[1].toFixed(1) + '" rx="' + (p[2] * 1.7).toFixed(1) + '" ry="' + p[2].toFixed(1) + '"/>';
|
||||
}).join('') +
|
||||
'<text class="stone-num" x="' + (cell.x + 11).toFixed(1) + '" y="' + (cell.y + 17).toFixed(1) + '">' + num + '</text>' +
|
||||
sealMark(cell.x + cell.w - 14, cell.y + cell.h - 13, core.seal) +
|
||||
'</g>';
|
||||
idx++;
|
||||
x += ww + gap;
|
||||
});
|
||||
});
|
||||
svg += '</svg>';
|
||||
wall.innerHTML = svg;
|
||||
state.faces = faces;
|
||||
// keep the selection valid
|
||||
if (state.selFace >= faces.length) state.selFace = -1;
|
||||
}
|
||||
|
||||
function indexDur(i) {
|
||||
const core = cores[state.sel];
|
||||
return core.dur / 16;
|
||||
}
|
||||
|
||||
wall.addEventListener('pointerdown', function (e) {
|
||||
const g = e.target.closest ? e.target.closest('.stone') : null;
|
||||
if (!g) return;
|
||||
const i = +g.dataset.i;
|
||||
if (state.selFace !== i) {
|
||||
state.selFace = i;
|
||||
$$('.stone', wall).forEach(function (el) {
|
||||
el.classList.toggle('is-sel', +el.dataset.i === i);
|
||||
});
|
||||
faceStatus();
|
||||
$('#place').disabled = false;
|
||||
const lb = $('#place-ring'); if (lb) lb.disabled = false;
|
||||
}
|
||||
const face = state.faces[i];
|
||||
strike(face);
|
||||
g.classList.remove('is-struck');
|
||||
requestAnimationFrame(function () { g.classList.add('is-struck'); });
|
||||
window.setTimeout(function () { g.classList.remove('is-struck'); }, 520);
|
||||
});
|
||||
|
||||
function faceStatus() {
|
||||
const el = $('#face-status');
|
||||
if (!el) return;
|
||||
if (state.selFace < 0 || !state.faces[state.selFace]) {
|
||||
el.textContent = 'strike a stone';
|
||||
return;
|
||||
}
|
||||
const f = state.faces[state.selFace];
|
||||
el.textContent = 'face ' + String(f.i + 1).padStart(2, '0') + ' · ' +
|
||||
f.t0.toFixed(2) + '–' + f.t1.toFixed(2) + ' s · seal ' + f.seal;
|
||||
}
|
||||
|
||||
/* ————— weather ————— */
|
||||
|
||||
function climateText() {
|
||||
const w = state.weather;
|
||||
const age = w.age < 0.4 ? 'young' : w.age < 0.7 ? 'middle' : 'old';
|
||||
const air = w.air < 0.35 ? 'dry' : w.air < 0.7 ? 'damp' : 'wet';
|
||||
const heat = w.heat < 0.35 ? 'cold' : w.heat < 0.7 ? 'warm' : 'hot';
|
||||
return age + ', ' + air + ', ' + heat;
|
||||
}
|
||||
|
||||
function updateClimate() {
|
||||
const el = $('#climate');
|
||||
if (el) el.textContent = climateText();
|
||||
}
|
||||
|
||||
function initWeather() {
|
||||
['age', 'pressure', 'air', 'heat'].forEach(function (k) {
|
||||
const el = $('#w-' + k);
|
||||
if (!el) return;
|
||||
state.weather[k] = +el.value / 100;
|
||||
const out = el.parentNode.querySelector('output');
|
||||
out.textContent = state.weather[k].toFixed(2);
|
||||
el.addEventListener('input', function () {
|
||||
state.weather[k] = +el.value / 100;
|
||||
out.textContent = state.weather[k].toFixed(2);
|
||||
updateClimate();
|
||||
if (E) E.setWeather(state.weather);
|
||||
});
|
||||
});
|
||||
updateClimate();
|
||||
if (E) E.setWeather(state.weather);
|
||||
}
|
||||
|
||||
/* ————— rings ————— */
|
||||
|
||||
function drawRings() {
|
||||
const box = $('#ring-visual');
|
||||
if (!box) return;
|
||||
const W = box.clientWidth, H = box.clientHeight;
|
||||
if (!W || !H) return;
|
||||
const cx = W / 2, cy = H / 2;
|
||||
let s = '<svg viewBox="0 0 ' + W + ' ' + H + '">';
|
||||
const n = Math.min(state.rings, 12);
|
||||
const maxR = Math.min(W * 0.46, H * 0.46);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const r = maxR * (i + 1) / Math.max(n, 4);
|
||||
const off = (i % 2 ? 1 : -1) * (1 + i * 0.3);
|
||||
s += '<ellipse class="ring-arc" cx="' + (cx + off).toFixed(1) + '" cy="' + cy + '" rx="' + r.toFixed(1) + '" ry="' + (r * 0.58).toFixed(1) + '" stroke-opacity="' + (0.34 + i * 0.05).toFixed(2) + '"/>';
|
||||
}
|
||||
s += '<circle cx="' + cx + '" cy="' + cy + '" r="1.2" fill="rgba(232,64,42,.9)"/></svg>';
|
||||
box.innerHTML = s;
|
||||
const count = $('#ring-count');
|
||||
if (count) count.textContent = String(state.rings).padStart(2, '0');
|
||||
}
|
||||
|
||||
/* ————— sound ————— */
|
||||
|
||||
function strike(face) {
|
||||
if (!E || !face) return;
|
||||
const core = cores[state.sel];
|
||||
if (!core) return;
|
||||
E.strike(core.id, face.i, { pitch: face.semis, gain: 0.9, ghost: state.ghost });
|
||||
}
|
||||
|
||||
/* ————— view, core select, fracture, place ————— */
|
||||
|
||||
function updateHeader() {
|
||||
const core = cores[state.sel];
|
||||
const name = $('#tray-name');
|
||||
const dur = $('#tray-dur');
|
||||
const sealEl = $('#tray-seal');
|
||||
if (name) name.textContent = core.name;
|
||||
if (dur) dur.textContent = core.dur.toFixed(1) + ' s';
|
||||
if (sealEl) {
|
||||
sealEl.className = 'seal seal--' + core.seal;
|
||||
const t = sealEl.querySelector('span');
|
||||
if (t) t.textContent = core.seal;
|
||||
}
|
||||
}
|
||||
|
||||
function selectCore(i, keepSeed) {
|
||||
state.sel = clamp(i, 0, cores.length - 1);
|
||||
state.selFace = -1;
|
||||
$('#place').disabled = true;
|
||||
const lb = $('#place-ring'); if (lb) lb.disabled = true;
|
||||
const core = cores[state.sel];
|
||||
state.lens = core.cuts[0] || 0;
|
||||
updateHeader();
|
||||
highlightStore();
|
||||
if (!keepSeed) state.faceSeed = core.seed * 13 + state.fracture * 7;
|
||||
redraw();
|
||||
faceStatus();
|
||||
}
|
||||
|
||||
function redraw() {
|
||||
drawTray();
|
||||
buildOverlay();
|
||||
buildFaces(state.faceSeed);
|
||||
drawRings();
|
||||
}
|
||||
|
||||
function fractureCore() {
|
||||
const core = cores[state.sel];
|
||||
state.fracture++;
|
||||
if (E) E.refracture(core.id);
|
||||
state.faceSeed = core.seed * 13 + state.fracture * 7;
|
||||
state.selFace = -1;
|
||||
state.lens = core.cuts[0];
|
||||
$('#place').disabled = true;
|
||||
const lb2 = $('#place-ring'); if (lb2) lb2.disabled = true;
|
||||
redraw();
|
||||
faceStatus();
|
||||
if (wall.animate) {
|
||||
wall.animate([{ opacity: 0.25 }, { opacity: 1 }], { duration: 420, easing: 'ease-out' });
|
||||
}
|
||||
log('faces.fracture', core.id + ' · 16 faces', '', 'hand');
|
||||
}
|
||||
|
||||
function placeFace(loop) {
|
||||
if (state.selFace < 0 || !state.faces[state.selFace]) return;
|
||||
const core = cores[state.sel];
|
||||
if (!core || !window.ZHILA || !window.ZHILA.addStoneClip) return;
|
||||
const f = state.faces[state.selFace];
|
||||
const label = 'face ' + String(f.i + 1).padStart(2, '0');
|
||||
const res = window.ZHILA.addStoneClip(label, f.seal, {
|
||||
core: core.id, face: f.i, ring: loop ? 2 : 0, pitch: 0, gain: 0.85
|
||||
});
|
||||
const st = $('#face-status');
|
||||
if (res) {
|
||||
if (st) st.textContent = (loop ? 'looping in ' : 'placed in ') + res.lane + ', bar ' + res.start + '–' + res.end;
|
||||
}
|
||||
}
|
||||
|
||||
function extract(by, query) {
|
||||
if (!E) return;
|
||||
const name = by === 'mcp'
|
||||
? (query.length > 26 ? query.slice(0, 26) + '…' : query)
|
||||
: (handBank.length ? handBank.shift() : 'new core');
|
||||
const core = E.newCore({ name: name, seal: by === 'mcp' ? 'found' : 'taken' });
|
||||
log('cores.extract', core.name, core.seal, by);
|
||||
}
|
||||
|
||||
/* ————— view switch ————— */
|
||||
|
||||
function setView(v) {
|
||||
document.body.dataset.view = v;
|
||||
$$('.view-btn').forEach(function (b) {
|
||||
const on = b.dataset.viewBtn === v;
|
||||
b.classList.toggle('is-on', on);
|
||||
b.setAttribute('aria-pressed', String(on));
|
||||
});
|
||||
if (v === 'quarry') {
|
||||
requestAnimationFrame(function () {
|
||||
selectCore(state.sel, true);
|
||||
});
|
||||
} else {
|
||||
requestAnimationFrame(function () {
|
||||
if (window.ZHILA && window.ZHILA.buildNave) window.ZHILA.buildNave();
|
||||
if (window.ZHILA && window.ZHILA.buildLattice) window.ZHILA.buildLattice();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$$('.view-btn').forEach(function (b) {
|
||||
b.addEventListener('click', function () { setView(b.dataset.viewBtn); });
|
||||
});
|
||||
|
||||
/* ————— tray scrubbing ————— */
|
||||
|
||||
let scrubbing = false;
|
||||
function scrub(e) {
|
||||
const r = stage.getBoundingClientRect();
|
||||
const g = trayGeom();
|
||||
if (!g.W) return;
|
||||
const x = clamp(e.clientX - r.left, g.x0, g.x1);
|
||||
const core = cores[state.sel];
|
||||
state.lens = +(((x - g.x0) / (g.x1 - g.x0)) * core.dur).toFixed(2);
|
||||
updateLensOnly();
|
||||
}
|
||||
stage.addEventListener('pointerdown', function (e) {
|
||||
scrubbing = true;
|
||||
stage.setPointerCapture(e.pointerId);
|
||||
scrub(e);
|
||||
e.preventDefault();
|
||||
});
|
||||
stage.addEventListener('pointermove', function (e) { if (scrubbing) scrub(e); });
|
||||
stage.addEventListener('pointerup', function () { scrubbing = false; });
|
||||
stage.addEventListener('pointercancel', function () { scrubbing = false; });
|
||||
|
||||
/* ————— wiring ————— */
|
||||
|
||||
$('#fracture').addEventListener('click', fractureCore);
|
||||
$('#place').addEventListener('click', function () { placeFace(false); });
|
||||
const loopBtn = $('#place-ring');
|
||||
if (loopBtn) loopBtn.addEventListener('click', function () { placeFace(true); });
|
||||
const updatePlace = function () {
|
||||
const on = state.selFace >= 0;
|
||||
$('#place').disabled = !on;
|
||||
if (loopBtn) loopBtn.disabled = !on;
|
||||
};
|
||||
|
||||
$('#ghost').addEventListener('click', function () {
|
||||
state.ghost = !state.ghost;
|
||||
this.setAttribute('aria-pressed', String(state.ghost));
|
||||
this.textContent = state.ghost ? 'on' : 'off';
|
||||
});
|
||||
|
||||
$('#grow').addEventListener('click', function () {
|
||||
state.rings = Math.min(state.rings + 1, 12);
|
||||
if (E) E.setRings(state.rings);
|
||||
drawRings();
|
||||
log('rings.grow', 'ring ' + String(state.rings).padStart(2, '0') + ' · ' + climateText(), '', 'hand');
|
||||
});
|
||||
|
||||
$('#extract').addEventListener('click', function () {
|
||||
extract('hand');
|
||||
});
|
||||
|
||||
const choirForm = $('#choir-call');
|
||||
const choirInput = $('#choir-input');
|
||||
choirForm.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
const q = choirInput.value.trim();
|
||||
if (!q || document.body.dataset.mode === 'hand') return;
|
||||
choirInput.value = '';
|
||||
choirForm.classList.add('is-out');
|
||||
window.setTimeout(function () {
|
||||
extract('mcp', q);
|
||||
choirForm.classList.remove('is-out');
|
||||
}, 950);
|
||||
});
|
||||
|
||||
function syncChoir() {
|
||||
const hand = document.body.dataset.mode === 'hand';
|
||||
choirInput.disabled = hand;
|
||||
choirInput.placeholder = hand ? 'the choir is resting' : 'send the choir for…';
|
||||
}
|
||||
new MutationObserver(syncChoir).observe(document.body, { attributes: true, attributeFilter: ['data-mode'] });
|
||||
choirForm.addEventListener('pointerdown', function () {
|
||||
if (document.body.dataset.mode === 'hand') {
|
||||
const btn = document.querySelector('[data-mode-btn="choir"]');
|
||||
if (btn) btn.click();
|
||||
window.setTimeout(function () { choirInput.focus(); }, 40);
|
||||
}
|
||||
});
|
||||
|
||||
let rt = null;
|
||||
window.addEventListener('resize', function () {
|
||||
window.clearTimeout(rt);
|
||||
rt = window.setTimeout(function () {
|
||||
if (document.body.dataset.view !== 'quarry') return;
|
||||
drawTray();
|
||||
buildOverlay();
|
||||
buildFaces(state.faceSeed);
|
||||
drawRings();
|
||||
}, 180);
|
||||
});
|
||||
|
||||
/* ————— boot ————— */
|
||||
|
||||
function setWeatherUI(w) {
|
||||
['age', 'pressure', 'air', 'heat'].forEach(function (k) {
|
||||
if (w[k] == null) return;
|
||||
state.weather[k] = clamp(+w[k], 0, 1);
|
||||
const el = $('#w-' + k);
|
||||
if (el) {
|
||||
el.value = Math.round(state.weather[k] * 100);
|
||||
const out = el.parentNode.querySelector('output');
|
||||
if (out) out.textContent = state.weather[k].toFixed(2);
|
||||
}
|
||||
});
|
||||
if (E) E.setWeather(state.weather);
|
||||
updateClimate();
|
||||
return state.weather;
|
||||
}
|
||||
|
||||
function setRingsUI(n) {
|
||||
state.rings = clamp(Math.round(+n || 0), 0, 12);
|
||||
if (E) E.setRings(state.rings);
|
||||
drawRings();
|
||||
return state.rings;
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
buildStore();
|
||||
selectCore(state.sel, true);
|
||||
}
|
||||
|
||||
function focusCore(id) {
|
||||
const idx = cores.findIndex(function (c) { return c.id === id; });
|
||||
if (idx >= 0) selectCore(idx);
|
||||
else selectCore(state.sel, true);
|
||||
}
|
||||
|
||||
function boot() {
|
||||
if (E) {
|
||||
E.onCores(function (core) {
|
||||
buildStore();
|
||||
const idx = E.cores.indexOf(core);
|
||||
if (idx >= 0) {
|
||||
state.sel = idx;
|
||||
if (document.body.dataset.view === 'quarry') selectCore(idx);
|
||||
else updateHeader();
|
||||
}
|
||||
});
|
||||
E.onLog(renderLedger);
|
||||
E.setRings(state.rings);
|
||||
}
|
||||
buildStore();
|
||||
initWeather();
|
||||
renderLedger();
|
||||
selectCore(0, false);
|
||||
syncChoir();
|
||||
}
|
||||
|
||||
if (document.readyState === 'complete' || document.readyState === 'interactive') {
|
||||
window.setTimeout(boot, 0);
|
||||
} else {
|
||||
document.addEventListener('DOMContentLoaded', boot);
|
||||
}
|
||||
|
||||
window.ZHILA_QUARRY = {
|
||||
setView: setView, state: state, cores: cores, focusCore: focusCore,
|
||||
setWeatherUI: setWeatherUI, setRingsUI: setRingsUI, refresh: refresh,
|
||||
weather: function () { return state.weather; },
|
||||
rings: function () { return state.rings; },
|
||||
selectedCore: function () { return cores[state.sel] || null; }
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,230 @@
|
||||
/* ZHILA · local server
|
||||
serves the instrument and runs the choir: a model with tools that act in the page. */
|
||||
|
||||
'use strict';
|
||||
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { SYSTEM, TOOLS } = require('./agent-tools');
|
||||
|
||||
const ROOT = __dirname;
|
||||
const PORT = Number(process.env.PORT || 4173);
|
||||
const HOST = '127.0.0.1';
|
||||
const MODEL = process.env.ZHILA_MODEL || 'deepseek-chat';
|
||||
const BASE = process.env.ZHILA_BASE_URL || 'https://api.deepseek.com';
|
||||
const KEY = process.env.DEEPSEEK_API_KEY || '';
|
||||
|
||||
const MIME = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.mp3': 'audio/mpeg',
|
||||
'.wav': 'audio/wav',
|
||||
'.woff2': 'font/woff2',
|
||||
'.ico': 'image/x-icon',
|
||||
'.md': 'text/plain; charset=utf-8'
|
||||
};
|
||||
|
||||
function send(res, code, type, body) {
|
||||
res.writeHead(code, {
|
||||
'Content-Type': type,
|
||||
'Cache-Control': 'no-store',
|
||||
'Access-Control-Allow-Headers': 'content-type',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS'
|
||||
});
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
function json(res, code, obj) {
|
||||
send(res, code, 'application/json; charset=utf-8', JSON.stringify(obj));
|
||||
}
|
||||
|
||||
function serveStatic(req, res, pathname) {
|
||||
let rel = pathname === '/' ? '/index.html' : pathname;
|
||||
try { rel = decodeURIComponent(rel); } catch (e) { return send(res, 400, 'text/plain', 'bad path'); }
|
||||
const file = path.resolve(ROOT, '.' + rel);
|
||||
if (!file.startsWith(ROOT + path.sep) || file === ROOT) return send(res, 403, 'text/plain', 'forbidden');
|
||||
if (!MIME[path.extname(file).toLowerCase()]) return send(res, 404, 'text/plain', 'not found');
|
||||
fs.readFile(file, function (err, data) {
|
||||
if (err) return send(res, 404, 'text/plain', 'not found');
|
||||
send(res, 200, MIME[path.extname(file).toLowerCase()], data);
|
||||
});
|
||||
}
|
||||
|
||||
let cachedKey = KEY;
|
||||
|
||||
/* The key may be exported to the launching environment; otherwise the start script injects it. */
|
||||
if (!cachedKey) {
|
||||
console.warn('[zhila] no DEEPSEEK_API_KEY — the agent will run in local mode');
|
||||
}
|
||||
|
||||
function normaliseMessages(input) {
|
||||
if (!Array.isArray(input)) throw new Error('messages must be an array');
|
||||
if (input.length > 80) throw new Error('conversation too long');
|
||||
let chars = 0;
|
||||
return input.map(function (m) {
|
||||
if (!m || typeof m !== 'object') throw new Error('bad message');
|
||||
const role = m.role;
|
||||
if (!['user', 'assistant', 'tool'].includes(role)) throw new Error('bad role');
|
||||
const out = { role: role };
|
||||
if (typeof m.content === 'string') {
|
||||
if (m.content.length > 12000) throw new Error('message too long');
|
||||
chars += m.content.length;
|
||||
out.content = m.content;
|
||||
} else if (m.content === null) {
|
||||
out.content = null;
|
||||
} else {
|
||||
out.content = '';
|
||||
}
|
||||
if (role === 'tool') {
|
||||
if (typeof m.tool_call_id !== 'string') throw new Error('tool message without id');
|
||||
out.tool_call_id = m.tool_call_id;
|
||||
if (m.name) out.name = String(m.name).slice(0, 80);
|
||||
}
|
||||
if (role === 'assistant' && Array.isArray(m.tool_calls) && m.tool_calls.length) {
|
||||
out.tool_calls = m.tool_calls.map(function (tc) {
|
||||
if (!tc || tc.type !== 'function' || !tc.function || typeof tc.function.name !== 'string') {
|
||||
throw new Error('bad tool call');
|
||||
}
|
||||
return {
|
||||
id: String(tc.id || '').slice(0, 80),
|
||||
type: 'function',
|
||||
function: {
|
||||
name: tc.function.name,
|
||||
arguments: typeof tc.function.arguments === 'string' ? tc.function.arguments.slice(0, 8000) : '{}'
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}).filter(function (m) {
|
||||
return m.content && m.content.length || m.tool_calls && m.tool_calls.length || m.role === 'tool';
|
||||
}).map(function (m) {
|
||||
if (chars > 200000) throw new Error('conversation too large');
|
||||
return m;
|
||||
});
|
||||
}
|
||||
|
||||
async function callModel(messages) {
|
||||
if (!cachedKey) {
|
||||
const err = new Error('no API key');
|
||||
err.code = 'NO_KEY';
|
||||
throw err;
|
||||
}
|
||||
const body = {
|
||||
model: MODEL,
|
||||
messages: [{ role: 'system', content: SYSTEM }].concat(messages),
|
||||
tools: TOOLS,
|
||||
tool_choice: 'auto',
|
||||
temperature: 0.75,
|
||||
max_tokens: 900,
|
||||
stream: false
|
||||
};
|
||||
const res = await fetch(BASE.replace(/\/+$/, '') + '/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + cachedKey,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(90000)
|
||||
});
|
||||
const text = await res.text();
|
||||
let data;
|
||||
try { data = JSON.parse(text); } catch (e) { throw new Error('model returned non-JSON (' + res.status + ')'); }
|
||||
if (!res.ok) {
|
||||
const msg = data && data.error && data.error.message ? data.error.message : 'model error ' + res.status;
|
||||
throw new Error(msg);
|
||||
}
|
||||
const choice = data.choices && data.choices[0];
|
||||
if (!choice || !choice.message) throw new Error('model returned no message');
|
||||
const m = choice.message;
|
||||
return {
|
||||
role: 'assistant',
|
||||
content: typeof m.content === 'string' ? m.content : '',
|
||||
tool_calls: Array.isArray(m.tool_calls) && m.tool_calls.length ? m.tool_calls : null
|
||||
};
|
||||
}
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
let size = 0;
|
||||
const chunks = [];
|
||||
req.on('data', function (c) {
|
||||
size += c.length;
|
||||
if (size > 400000) { reject(new Error('body too large')); req.destroy(); return; }
|
||||
chunks.push(c);
|
||||
});
|
||||
req.on('end', function () {
|
||||
try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')); }
|
||||
catch (e) { reject(new Error('bad JSON')); }
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function originAllowed(req) {
|
||||
const o = req.headers.origin;
|
||||
if (!o) return null;
|
||||
if (o === 'null') return 'null';
|
||||
if (/^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/.test(o)) return o;
|
||||
return false;
|
||||
}
|
||||
|
||||
const server = http.createServer(async function (req, res) {
|
||||
const url = new URL(req.url, 'http://' + req.headers.host);
|
||||
const origin = originAllowed(req);
|
||||
if (origin) {
|
||||
res.setHeader('Access-Control-Allow-Origin', origin);
|
||||
res.setHeader('Vary', 'Origin');
|
||||
}
|
||||
if (req.method === 'OPTIONS') return send(res, 204, 'text/plain', '');
|
||||
if (url.pathname.startsWith('/api/') && origin === false) {
|
||||
return json(res, 403, { error: 'origin not allowed' });
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/agent' && req.method === 'GET') {
|
||||
return json(res, 200, {
|
||||
live: !!cachedKey,
|
||||
mode: cachedKey ? 'model' : 'local',
|
||||
model: cachedKey ? MODEL : null
|
||||
});
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/agent' && req.method === 'POST') {
|
||||
let body;
|
||||
try { body = await readBody(req); }
|
||||
catch (e) { return json(res, 400, { error: e.message }); }
|
||||
let messages;
|
||||
try { messages = normaliseMessages(body.messages); }
|
||||
catch (e) { return json(res, 400, { error: e.message }); }
|
||||
try {
|
||||
const message = await callModel(messages);
|
||||
return json(res, 200, { message: message, model: MODEL });
|
||||
} catch (e) {
|
||||
if (e.code === 'NO_KEY') {
|
||||
return json(res, 503, { error: 'agent offline: no API key on the server', mode: 'local' });
|
||||
}
|
||||
console.error('[zhila] model error:', e.message);
|
||||
return json(res, 502, { error: e.message || 'model error' });
|
||||
}
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/')) return json(res, 404, { error: 'no such endpoint' });
|
||||
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') return send(res, 405, 'text/plain', 'method not allowed');
|
||||
serveStatic(req, res, url.pathname);
|
||||
});
|
||||
|
||||
server.listen(PORT, HOST, function () {
|
||||
console.log('');
|
||||
console.log(' ZHILA · http://' + HOST + ':' + PORT);
|
||||
console.log(' agent · ' + (cachedKey ? MODEL + ' (live)' : 'local conductor (no key)'));
|
||||
console.log('');
|
||||
});
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
if [ -z "${DEEPSEEK_API_KEY:-}" ]; then
|
||||
DEEPSEEK_API_KEY="$(python3 - <<'PY'
|
||||
import os
|
||||
try:
|
||||
import yaml
|
||||
d = yaml.safe_load(open(os.path.expanduser('~/.dsh/.credentials.yaml')))
|
||||
print((d.get('refs') or {}).get('DEEPSEEK_API_KEY', ''))
|
||||
except Exception:
|
||||
print('')
|
||||
PY
|
||||
)"
|
||||
export DEEPSEEK_API_KEY
|
||||
fi
|
||||
|
||||
if [ -z "${DEEPSEEK_API_KEY}" ]; then
|
||||
echo "no DEEPSEEK_API_KEY — the choir will run in local mode"
|
||||
fi
|
||||
|
||||
exec node server.js
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,399 @@
|
||||
/* жила · рельеф
|
||||
A single contour field over the whole surface: domain-warped fbm for the strata,
|
||||
a radial well under the eye, perspective compression below the horizon,
|
||||
and black pools cut where the interface needs to speak. */
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const P = {
|
||||
scale: 1 / 1.35, // field resolution vs. screen
|
||||
density: 17, // contour levels per unit of the field
|
||||
freq: 4.7, // base frequency of the strata
|
||||
oct: 5,
|
||||
warp: 0.66,
|
||||
well: 2.35,
|
||||
line: 0.68, // half-width of a contour, in pixels
|
||||
bloom: 0.5
|
||||
};
|
||||
const SCALE = P.scale;
|
||||
const canvas = document.getElementById('terrain');
|
||||
const ctx = canvas.getContext('2d', { alpha: false });
|
||||
const spiresCanvas = document.getElementById('spires');
|
||||
if (!canvas) return;
|
||||
|
||||
const anchors = {
|
||||
eye: { x: 0.5, y: 0.3, sig: 0.13 },
|
||||
hor: 0.6
|
||||
};
|
||||
|
||||
/* ————— noise ————— */
|
||||
|
||||
function hash(x, y) {
|
||||
let h = Math.imul(x, 374761393) ^ Math.imul(y, 668265263);
|
||||
h = Math.imul(h ^ (h >>> 13), 1274126177);
|
||||
h ^= h >>> 16;
|
||||
return (h >>> 0) / 4294967296;
|
||||
}
|
||||
|
||||
function noise(x, y) {
|
||||
const xi = Math.floor(x), yi = Math.floor(y);
|
||||
const xf = x - xi, yf = y - yi;
|
||||
const u = xf * xf * (3 - 2 * xf);
|
||||
const v = yf * yf * (3 - 2 * yf);
|
||||
const a = hash(xi, yi), b = hash(xi + 1, yi);
|
||||
const c = hash(xi, yi + 1), d = hash(xi + 1, yi + 1);
|
||||
const ab = a + (b - a) * u;
|
||||
const cd = c + (d - c) * u;
|
||||
return ab + (cd - ab) * v;
|
||||
}
|
||||
|
||||
function fbm(x, y, oct) {
|
||||
let s = 0, amp = 0.5, f = 1, norm = 0;
|
||||
for (let i = 0; i < oct; i++) {
|
||||
s += amp * noise(x * f, y * f);
|
||||
norm += amp;
|
||||
f *= 2.04;
|
||||
amp *= 0.5;
|
||||
}
|
||||
return s / norm;
|
||||
}
|
||||
|
||||
const smooth = (a, b, x) => {
|
||||
let t = (x - a) / (b - a);
|
||||
t = t < 0 ? 0 : t > 1 ? 1 : t;
|
||||
return t * t * (3 - 2 * t);
|
||||
};
|
||||
const clamp01 = v => v < 0 ? 0 : v > 1 ? 1 : v;
|
||||
|
||||
/* ————— anchors: where the eye and the horizon actually are ————— */
|
||||
|
||||
function measure() {
|
||||
const voidEl = document.querySelector('.void');
|
||||
const stage = document.getElementById('stage');
|
||||
const h = window.innerHeight;
|
||||
if (voidEl) {
|
||||
const r = voidEl.getBoundingClientRect();
|
||||
anchors.eye.x = (r.left + r.width / 2) / h;
|
||||
anchors.eye.y = (r.top + r.height / 2) / h;
|
||||
anchors.eye.sig = (r.width * 0.95) / h;
|
||||
}
|
||||
if (stage) {
|
||||
const sr = stage.getBoundingClientRect();
|
||||
anchors.hor = (sr.top + sr.height * 0.28) / h;
|
||||
}
|
||||
}
|
||||
|
||||
/* ————— black pools around the interface ————— */
|
||||
|
||||
function blob(g, x, y, w, h, seed) {
|
||||
const pad = 15;
|
||||
x -= pad; y -= pad; w += pad * 2; h += pad * 2;
|
||||
let s = seed * 9301 + 49297;
|
||||
const rnd = () => { s = (s * 9301 + 49297) % 233280; return s / 233280; };
|
||||
const jit = 9;
|
||||
const step = 34;
|
||||
const nx = Math.max(2, Math.round(w / step));
|
||||
const ny = Math.max(2, Math.round(h / step));
|
||||
const pts = [];
|
||||
for (let i = 0; i <= nx; i++) pts.push([x + (w * i) / nx, y + (rnd() - 0.5) * jit]);
|
||||
for (let i = 1; i <= ny; i++) pts.push([x + w + (rnd() - 0.5) * jit, y + (h * i) / ny]);
|
||||
for (let i = nx - 1; i >= 0; i--) pts.push([x + (w * i) / nx, y + h + (rnd() - 0.5) * jit]);
|
||||
for (let i = ny - 1; i > 0; i--) pts.push([x + (rnd() - 0.5) * jit, y + (h * i) / ny]);
|
||||
g.beginPath();
|
||||
g.moveTo(pts[0][0], pts[0][1]);
|
||||
for (let i = 1; i < pts.length; i++) g.lineTo(pts[i][0], pts[i][1]);
|
||||
g.closePath();
|
||||
}
|
||||
|
||||
let maskCache = null;
|
||||
|
||||
function buildMask(mw, mh) {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = mw; c.height = mh;
|
||||
const g = c.getContext('2d');
|
||||
g.clearRect(0, 0, mw, mh);
|
||||
g.fillStyle = '#fff';
|
||||
g.filter = 'blur(11px)';
|
||||
const els = Array.prototype.slice.call(document.querySelectorAll('[data-pool]'));
|
||||
els.forEach((el, i) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width < 4 || r.height < 4) return;
|
||||
if (r.bottom < -40 || r.top > window.innerHeight + 40) return;
|
||||
g.globalAlpha = parseFloat(el.dataset.pool) || 0.9;
|
||||
blob(g, r.left * SCALE, r.top * SCALE, r.width * SCALE, r.height * SCALE, i + 3);
|
||||
g.fill();
|
||||
});
|
||||
g.filter = 'none';
|
||||
const data = g.getImageData(0, 0, mw, mh).data;
|
||||
maskCache = data;
|
||||
return data;
|
||||
}
|
||||
|
||||
/* ————— the field ————— */
|
||||
|
||||
function render() {
|
||||
measure();
|
||||
const W = canvas.clientWidth || window.innerWidth;
|
||||
const H = canvas.clientHeight || window.innerHeight;
|
||||
const dpr = Math.min(1.6, window.devicePixelRatio || 1);
|
||||
canvas.width = Math.round(W * dpr);
|
||||
canvas.height = Math.round(H * dpr);
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
ctx.fillStyle = '#080504';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const mw = Math.max(8, Math.round(W * SCALE));
|
||||
const mh = Math.max(8, Math.round(H * SCALE));
|
||||
const mask = buildMask(mw, mh);
|
||||
const e = anchors.eye;
|
||||
const hor = anchors.hor;
|
||||
const sig2 = 2 * e.sig * e.sig;
|
||||
const field = new Float32Array(mw * mh);
|
||||
|
||||
/* pass 1: the terrain itself */
|
||||
for (let j = 0; j < mh; j++) {
|
||||
const py = (j + 0.5) / mh;
|
||||
const below = smooth(hor - 0.01, hor + 0.12, py);
|
||||
const dd = Math.max(py - hor, 0.006);
|
||||
let persp = 1.55 / dd;
|
||||
if (persp > 2.15) persp = 2.15;
|
||||
const sy = 1 + below * (persp - 1) * 0.8;
|
||||
const sx = 1 + below * 0.3;
|
||||
|
||||
for (let i = 0; i < mw; i++) {
|
||||
const px = (i + 0.5) / mh;
|
||||
const wx = fbm(px * 2.05 + 13.7, py * 2.42 + 4.2, 3);
|
||||
const wy = fbm(px * 2.05 + 7.1, py * 2.42 + 9.6, 3);
|
||||
const qx = px + (wx - 0.5) * P.warp;
|
||||
const qy = py + (wy - 0.5) * P.warp;
|
||||
const dx = px - e.x, dy = py - e.y;
|
||||
const well = Math.exp(-(dx * dx + dy * dy) / sig2);
|
||||
let n = fbm(qx * P.freq * sx + 19.1, qy * P.freq * sy + 2.7, P.oct);
|
||||
n -= P.well * well;
|
||||
field[j * mw + i] = n;
|
||||
}
|
||||
}
|
||||
|
||||
/* pass 2: contours with a constant ink width */
|
||||
const img = new ImageData(mw, mh);
|
||||
const data = img.data;
|
||||
const px2 = 1 / mh;
|
||||
|
||||
for (let j = 0; j < mh; j++) {
|
||||
const py = (j + 0.5) / mh;
|
||||
const below = smooth(hor - 0.01, hor + 0.12, py);
|
||||
const dim = 1 - 0.4 * below;
|
||||
const j0 = Math.max(0, j - 1), j1 = Math.min(mh - 1, j + 1);
|
||||
for (let i = 0; i < mw; i++) {
|
||||
const idx = j * mw + i;
|
||||
const n = field[idx];
|
||||
const i0 = Math.max(0, i - 1), i1 = Math.min(mw - 1, i + 1);
|
||||
const gx = (field[j * mw + i1] - field[j * mw + i0]) * 0.5;
|
||||
const gy = (field[j1 * mw + i] - field[j0 * mw + i]) * 0.5;
|
||||
let g = Math.sqrt(gx * gx + gy * gy);
|
||||
if (g < 0.00035) g = 0.00035;
|
||||
|
||||
const v = n * P.density;
|
||||
const fr = v - Math.floor(v);
|
||||
const edge = (fr < 0.5 ? fr : 1 - fr) / P.density; // distance to the line, in field units
|
||||
const ink = P.line * g * (0.82 + 0.5 * Math.min(1, Math.abs(n) * 2));
|
||||
let line = 1 - edge / ink;
|
||||
if (line < 0) line = 0;
|
||||
line = Math.pow(line, 0.8);
|
||||
|
||||
const px = (i + 0.5) / mh;
|
||||
const dx = px - e.x, dy = py - e.y;
|
||||
const well = Math.exp(-(dx * dx + dy * dy) / sig2);
|
||||
|
||||
const m = 1 - (mask[idx * 4 + 3] / 255) * 0.975;
|
||||
const vy = 1 - Math.abs(py - 0.12) * 0.9;
|
||||
const a = clamp01(line * m * dim * (1 + 0.4 * well) * (0.82 + 0.3 * vy));
|
||||
const wash = clamp01((0.018 + 0.13 * well) * m * dim * (1 - line) * (0.7 + 0.6 * vy));
|
||||
const hot = clamp01(well * 1.1 + n * 0.42);
|
||||
const p = idx * 4;
|
||||
data[p] = 196 + 52 * hot;
|
||||
data[p + 1] = 40 + 30 * hot + 14 * n;
|
||||
data[p + 2] = 28 + 16 * hot;
|
||||
data[p + 3] = (a + wash * (1 - a)) * 255;
|
||||
}
|
||||
}
|
||||
|
||||
const tmp = document.createElement('canvas');
|
||||
tmp.width = mw; tmp.height = mh;
|
||||
tmp.getContext('2d').putImageData(img, 0, 0);
|
||||
|
||||
ctx.save();
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
ctx.globalCompositeOperation = 'lighter';
|
||||
ctx.globalAlpha = P.bloom;
|
||||
ctx.filter = 'blur(8px)';
|
||||
ctx.drawImage(tmp, 0, 0, canvas.width, canvas.height);
|
||||
ctx.filter = 'none';
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.drawImage(tmp, 0, 0, canvas.width, canvas.height);
|
||||
ctx.restore();
|
||||
|
||||
const g = ctx.createRadialGradient(
|
||||
canvas.width * 0.5, canvas.height * 0.36, 0,
|
||||
canvas.width * 0.5, canvas.height * 0.36, Math.max(canvas.width, canvas.height) * 0.78
|
||||
);
|
||||
g.addColorStop(0, 'rgba(0,0,0,0)');
|
||||
g.addColorStop(1, 'rgba(0,0,0,.38)');
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
/* ————— the city at the end of the road ————— */
|
||||
|
||||
function mulberry32(a) {
|
||||
return function () {
|
||||
a |= 0; 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 spire(g, x, base, h, w, rnd, alpha) {
|
||||
const tipX = x + (rnd() - 0.5) * w * 0.32;
|
||||
const tipY = base - h;
|
||||
|
||||
const halfAt = function (t) {
|
||||
let k = Math.pow(1 - t, 0.62);
|
||||
k *= 1 + 0.1 * Math.sin(t * 7 + w * 0.5);
|
||||
if (t > 0.34) k *= 1 - 0.15 * (t - 0.34);
|
||||
return Math.max(0.5, w * 0.5 * k);
|
||||
};
|
||||
const cxAt = function (t) { return x + (tipX - x) * Math.pow(t, 1.6); };
|
||||
|
||||
const N = 20;
|
||||
const pts = [];
|
||||
for (let i = 0; i <= N; i++) {
|
||||
const t = i / N;
|
||||
pts.push([cxAt(t) - halfAt(t), base - h * t]);
|
||||
}
|
||||
pts.push([tipX, tipY]);
|
||||
for (let i = N; i >= 0; i--) {
|
||||
const t = i / N;
|
||||
pts.push([cxAt(t) + halfAt(t), base - h * t]);
|
||||
}
|
||||
|
||||
const body = new Path2D();
|
||||
body.moveTo(pts[0][0], pts[0][1]);
|
||||
for (let i = 1; i < pts.length; i++) body.lineTo(pts[i][0], pts[i][1]);
|
||||
body.closePath();
|
||||
|
||||
g.fillStyle = 'rgba(58,42,35,.34)';
|
||||
g.fill(body);
|
||||
g.strokeStyle = 'rgba(220,206,190,' + (0.5 * alpha).toFixed(2) + ')';
|
||||
g.lineWidth = 0.8;
|
||||
g.stroke(body);
|
||||
|
||||
/* ribs: the vertical grain of the crystal */
|
||||
const ribs = 4;
|
||||
g.lineWidth = 0.6;
|
||||
g.strokeStyle = 'rgba(208,194,180,' + (0.26 * alpha).toFixed(2) + ')';
|
||||
for (let k = -ribs; k <= ribs; k++) {
|
||||
const f = k / ribs;
|
||||
g.beginPath();
|
||||
g.moveTo(x + f * w * 0.48, base);
|
||||
g.lineTo(cxAt(1) + f * 1.6, tipY + 1);
|
||||
g.stroke();
|
||||
}
|
||||
|
||||
/* strata, straight: a wireframe, not a vase */
|
||||
const bands = 7 + Math.floor(rnd() * 5);
|
||||
g.strokeStyle = 'rgba(208,194,180,' + (0.16 * alpha).toFixed(2) + ')';
|
||||
for (let i = 1; i <= bands; i++) {
|
||||
const t = Math.pow(i / (bands + 1), 1.1);
|
||||
const cx = cxAt(t);
|
||||
const y = base - h * t;
|
||||
const hw = halfAt(t) * 0.97;
|
||||
g.beginPath();
|
||||
g.moveTo(cx - hw, y);
|
||||
g.lineTo(cx + hw, y);
|
||||
g.stroke();
|
||||
}
|
||||
|
||||
/* a few braces, to catch the light */
|
||||
g.strokeStyle = 'rgba(208,194,180,' + (0.13 * alpha).toFixed(2) + ')';
|
||||
for (let i = 1; i <= 2; i++) {
|
||||
const t0 = 0.16 + i * 0.22;
|
||||
const t1 = t0 + 0.12;
|
||||
const hw0 = halfAt(t0), hw1 = halfAt(t1);
|
||||
g.beginPath();
|
||||
g.moveTo(cxAt(t0) - hw0, base - h * t0);
|
||||
g.lineTo(cxAt(t1) + hw1, base - h * t1);
|
||||
g.moveTo(cxAt(t0) + hw0, base - h * t0);
|
||||
g.lineTo(cxAt(t1) - hw1, base - h * t1);
|
||||
g.stroke();
|
||||
}
|
||||
|
||||
if (rnd() > 0.35) {
|
||||
g.beginPath();
|
||||
g.moveTo(tipX, tipY + 2);
|
||||
g.lineTo(tipX, tipY - h * 0.14);
|
||||
g.strokeStyle = 'rgba(224,210,194,' + (0.4 * alpha).toFixed(2) + ')';
|
||||
g.lineWidth = 0.7;
|
||||
g.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
function drawSpires() {
|
||||
if (!spiresCanvas) return;
|
||||
const W = spiresCanvas.clientWidth;
|
||||
const H = spiresCanvas.clientHeight;
|
||||
if (!W || !H) return;
|
||||
const dpr = Math.min(2, window.devicePixelRatio || 1);
|
||||
spiresCanvas.width = Math.round(W * dpr);
|
||||
spiresCanvas.height = Math.round(H * dpr);
|
||||
const g = spiresCanvas.getContext('2d');
|
||||
g.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
g.clearRect(0, 0, W, H);
|
||||
|
||||
const horizon = H * 0.7;
|
||||
const dark = g.createLinearGradient(0, 0, 0, horizon);
|
||||
dark.addColorStop(0, 'rgba(5,3,2,.62)');
|
||||
dark.addColorStop(0.72, 'rgba(5,3,2,.2)');
|
||||
dark.addColorStop(1, 'rgba(5,3,2,0)');
|
||||
g.fillStyle = dark;
|
||||
g.fillRect(0, 0, W, horizon + 2);
|
||||
|
||||
const haze = g.createLinearGradient(0, horizon - H * 0.55, 0, horizon + H * 0.14);
|
||||
haze.addColorStop(0, 'rgba(232,64,42,0)');
|
||||
haze.addColorStop(1, 'rgba(232,64,42,.1)');
|
||||
g.fillStyle = haze;
|
||||
g.fillRect(0, horizon - H * 0.55, W, H);
|
||||
|
||||
const layers = [
|
||||
{ hMin: 0.14, hMax: 0.42, alpha: 0.42, wid: 0.8, seed: 41, spread: 0.045 },
|
||||
{ hMin: 0.3, hMax: 1.0, alpha: 1, wid: 1, seed: 94, spread: 0.05 }
|
||||
];
|
||||
const clusters = [0.045, 0.15, 0.27, 0.335, 0.665, 0.73, 0.85, 0.955];
|
||||
layers.forEach(function (L) {
|
||||
const rnd = mulberry32(L.seed);
|
||||
const picked = [];
|
||||
clusters.forEach(function (cx) {
|
||||
const n = 1 + Math.floor(rnd() * 2.6);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const t = cx + (rnd() - 0.5) * L.spread * 2;
|
||||
if (t < 0.02 || t > 0.98) continue;
|
||||
if (t > 0.39 && t < 0.61) continue;
|
||||
const edge = Math.min(1, Math.abs(t - 0.5) * 2.1);
|
||||
picked.push({
|
||||
x: t * W,
|
||||
h: H * (L.hMin + rnd() * (L.hMax - L.hMin)) * (0.55 + 0.6 * edge),
|
||||
w: (7 + rnd() * 16) * L.wid * (0.75 + 0.5 * edge),
|
||||
a: L.alpha * (0.75 + rnd() * 0.25)
|
||||
});
|
||||
}
|
||||
});
|
||||
picked.sort(function (a, b) { return a.h - b.h; });
|
||||
picked.forEach(function (p) { spire(g, p.x, horizon + 2, p.h, p.w, rnd, p.a); });
|
||||
});
|
||||
}
|
||||
|
||||
window.ZHILA_TERRAIN = { render, drawSpires, measure, P };
|
||||
})();
|
||||
Reference in New Issue
Block a user