Patch language with osc/noise/trig/seq/adsr/filter/delay/poly + voice templates and inline live values. Two runtimes: - code_sinth/ — Python engine (numpy + sounddevice). Hot-reload via mtime watcher. Offline render to WAV. Static-HTTP+WS visualizer (viz/) that injects waveforms next to each `node X = ...` line. - web/ — port of the engine to JS running in AudioWorklet. Single static page with CodeMirror 6 editor (line widgets for live waveforms) and a control surface on the right with knobs/faders/step_seq/piano_roll declared from the patch. State preserved across hot-reload. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
163 lines
5.5 KiB
HTML
163 lines
5.5 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>code-sinth viz</title>
|
|
<style>
|
|
:root {
|
|
--bg: #0e0f12;
|
|
--fg: #d6dae0;
|
|
--gutter: #2a2f38;
|
|
--comment: #5a6470;
|
|
--kw: #c678dd;
|
|
--num: #d19a66;
|
|
--wave: #7af0c0;
|
|
--wave-bg: #161a1f;
|
|
}
|
|
* { box-sizing: border-box; }
|
|
html, body { height: 100%; margin: 0; background: var(--bg); color: var(--fg);
|
|
font-family: 'JetBrains Mono', 'Cascadia Code', Consolas, monospace;
|
|
font-size: 14px; }
|
|
body { display: flex; flex-direction: column; }
|
|
header { padding: 8px 14px; font-size: 12px; color: var(--gutter);
|
|
border-bottom: 1px solid #1c1f25; display: flex; gap: 14px;
|
|
flex-wrap: wrap; align-items: center; }
|
|
header .dot { width: 8px; height: 8px; border-radius: 50%; background: #555;
|
|
display: inline-block; vertical-align: middle; margin-right: 6px; }
|
|
header .dot.live { background: var(--wave); box-shadow: 0 0 8px var(--wave); }
|
|
#patch { flex: 1; overflow: auto; padding: 16px 0; }
|
|
.line { display: flex; align-items: center; min-height: 22px;
|
|
padding: 0 14px 0 0; white-space: pre; }
|
|
.ln { color: var(--gutter); width: 38px; flex: none; text-align: right;
|
|
padding-right: 12px; user-select: none; }
|
|
.code { white-space: pre; }
|
|
.kw { color: var(--kw); }
|
|
.num { color: var(--num); }
|
|
.com { color: var(--comment); font-style: italic; }
|
|
.wave { display: inline-block; margin-left: 12px; background: var(--wave-bg);
|
|
border-radius: 3px; vertical-align: middle; }
|
|
.empty { color: var(--gutter); }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<span><span id="dot" class="dot"></span><span id="status">connecting…</span></span>
|
|
<span id="info"></span>
|
|
<span id="diag" style="margin-left:auto;color:var(--comment);"></span>
|
|
</header>
|
|
<div id="patch"></div>
|
|
|
|
<script>
|
|
const taps = {}; // name -> Array<float>
|
|
let lastPatch = null;
|
|
|
|
const dot = document.getElementById('dot');
|
|
const status = document.getElementById('status');
|
|
const info = document.getElementById('info');
|
|
const diag = document.getElementById('diag');
|
|
|
|
function setStatus(live, text) { dot.classList.toggle('live', live); status.textContent = text; }
|
|
|
|
// ---- patch rendering (line-by-line, inserts canvas after `node X = ...`) ----
|
|
function highlight(line) {
|
|
// Very small syntax sprinkle: keywords, numbers, comments.
|
|
const esc = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
const ci = line.indexOf('#');
|
|
if (ci !== -1) {
|
|
return esc(line.slice(0, ci)) + `<span class="com">${esc(line.slice(ci))}</span>`;
|
|
}
|
|
return esc(line)
|
|
.replace(/\b(node|out|voice)\b/g, '<span class="kw">$1</span>')
|
|
.replace(/\b(\d+\.\d+|\d+)\b/g, '<span class="num">$1</span>');
|
|
}
|
|
|
|
function rebuild(patchText) {
|
|
const container = document.getElementById('patch');
|
|
container.innerHTML = '';
|
|
const lines = patchText.split('\n');
|
|
const re = /^\s*node\s+([A-Za-z_][A-Za-z0-9_]*)\s*=/;
|
|
lines.forEach((line, i) => {
|
|
const div = document.createElement('div');
|
|
div.className = 'line';
|
|
div.innerHTML = `<span class="ln">${i + 1}</span><span class="code">${highlight(line) || ' '}</span>`;
|
|
const m = re.exec(line);
|
|
if (m) {
|
|
const c = document.createElement('canvas');
|
|
c.width = 240; c.height = 30;
|
|
c.dataset.tap = m[1];
|
|
c.className = 'wave';
|
|
div.appendChild(c);
|
|
}
|
|
container.appendChild(div);
|
|
});
|
|
}
|
|
|
|
// ---- websocket ----
|
|
function connect() {
|
|
const url = `ws://${location.hostname || 'localhost'}:9000`;
|
|
setStatus(false, 'connecting…');
|
|
const ws = new WebSocket(url);
|
|
ws.onopen = () => setStatus(true, 'connected');
|
|
ws.onclose = () => { setStatus(false, 'disconnected — retry in 1s'); setTimeout(connect, 1000); };
|
|
ws.onerror = () => ws.close();
|
|
ws.onmessage = (ev) => {
|
|
const msg = JSON.parse(ev.data);
|
|
info.textContent = `sr=${msg.sr} block=${msg.block_size} taps=${Object.keys(msg.taps).length}`;
|
|
for (const [name, arr] of Object.entries(msg.taps)) taps[name] = arr;
|
|
if (msg.patch !== lastPatch) {
|
|
lastPatch = msg.patch;
|
|
rebuild(msg.patch || '# (empty patch)');
|
|
}
|
|
};
|
|
}
|
|
connect();
|
|
|
|
// ---- draw loop ----
|
|
function drawCanvas(c) {
|
|
const samples = taps[c.dataset.tap];
|
|
const ctx = c.getContext('2d');
|
|
const w = c.width, h = c.height;
|
|
const css = getComputedStyle(document.body);
|
|
ctx.fillStyle = css.getPropertyValue('--wave-bg').trim();
|
|
ctx.fillRect(0, 0, w, h);
|
|
if (!samples || samples.length === 0) {
|
|
ctx.fillStyle = '#3a4048'; ctx.font = '10px monospace';
|
|
ctx.fillText('no data', 6, h - 8);
|
|
return;
|
|
}
|
|
let peak = 0;
|
|
for (let i = 0; i < samples.length; i++) {
|
|
const a = Math.abs(samples[i]); if (a > peak) peak = a;
|
|
}
|
|
const scale = peak > 0.001 ? 1 / Math.max(peak, 0.05) : 1;
|
|
ctx.strokeStyle = css.getPropertyValue('--wave').trim();
|
|
ctx.lineWidth = 1.2;
|
|
ctx.beginPath();
|
|
for (let i = 0; i < samples.length; i++) {
|
|
const x = (i / (samples.length - 1)) * w;
|
|
const y = h * 0.5 - samples[i] * scale * (h * 0.45);
|
|
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
|
}
|
|
ctx.stroke();
|
|
if (peak < 0.5 && peak > 0) {
|
|
ctx.fillStyle = '#5a6470';
|
|
ctx.font = '9px monospace';
|
|
ctx.fillText(`pk ${peak.toFixed(2)}`, 4, 11);
|
|
}
|
|
}
|
|
|
|
let frames = 0;
|
|
function tick() {
|
|
const canvases = document.querySelectorAll('canvas[data-tap]');
|
|
canvases.forEach(drawCanvas);
|
|
frames++;
|
|
if (frames % 30 === 0) {
|
|
diag.textContent = `${canvases.length} canvases • ${Object.keys(taps).length} taps known`;
|
|
}
|
|
requestAnimationFrame(tick);
|
|
}
|
|
requestAnimationFrame(tick);
|
|
</script>
|
|
</body>
|
|
</html>
|