Files
code-sinth/viz/index.html
Jose Luis Montañes 7debc7436e initial: code-sinth — DSL-driven modular synth (Python engine + web app)
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>
2026-05-01 17:37:06 +02:00

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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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) || '&nbsp;'}</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>