Files
code-sinth/web/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

844 lines
33 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>code-sinth</title>
<!-- importmap: every @codemirror/* package pinned and forced to share a single
instance of @codemirror/state via esm.sh's ?external= flag. This is what
fixes the "widgets never appear" bug from before. -->
<script type="importmap">
{
"imports": {
"@codemirror/state": "https://esm.sh/@codemirror/state@6.4.1",
"@codemirror/view": "https://esm.sh/@codemirror/view@6.26.3?external=@codemirror/state",
"@codemirror/language": "https://esm.sh/@codemirror/language@6.10.1?external=@codemirror/state,@codemirror/view,@lezer/highlight,@lezer/common,@lezer/lr",
"@codemirror/commands": "https://esm.sh/@codemirror/commands@6.3.3?external=@codemirror/state,@codemirror/view,@codemirror/language",
"@codemirror/search": "https://esm.sh/@codemirror/search@6.5.6?external=@codemirror/state,@codemirror/view",
"@codemirror/autocomplete": "https://esm.sh/@codemirror/autocomplete@6.12.0?external=@codemirror/state,@codemirror/view,@codemirror/language",
"@codemirror/lint": "https://esm.sh/@codemirror/lint@6.5.0?external=@codemirror/state,@codemirror/view",
"@lezer/common": "https://esm.sh/@lezer/common@1.2.1",
"@lezer/highlight": "https://esm.sh/@lezer/highlight@1.2.0?external=@lezer/common",
"@lezer/lr": "https://esm.sh/@lezer/lr@1.4.0?external=@lezer/common",
"codemirror": "https://esm.sh/codemirror@6.0.1?external=@codemirror/state,@codemirror/view,@codemirror/language,@codemirror/commands,@codemirror/search,@codemirror/autocomplete,@codemirror/lint",
"@codemirror/theme-one-dark": "https://esm.sh/@codemirror/theme-one-dark@6.1.2?external=@codemirror/state,@codemirror/view,@codemirror/language,@lezer/highlight"
}
}
</script>
<style>
:root {
--bg: #0e0f12;
--panel: #14161a;
--gutter: #2a2f38;
--fg: #d6dae0;
--comment: #5a6470;
--kw: #c678dd;
--num: #d19a66;
--atom: #56b6c2;
--func: #61afef;
--wave: #7af0c0;
--wave-bg: #161a1f;
--error: #f08080;
--accent: #7af0c0;
--knob-track: #2a2f38;
}
* { 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(--comment);
border-bottom: 1px solid #1c1f25; display: flex; gap: 14px;
flex-wrap: wrap; align-items: center; }
header button { background: var(--panel); border: 1px solid var(--gutter);
color: var(--fg); padding: 5px 14px; cursor: pointer;
font-family: inherit; font-size: 12px; border-radius: 3px; }
header button:hover { border-color: var(--accent); color: var(--accent); }
header button:disabled { opacity: 0.4; cursor: default; }
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(--accent); box-shadow: 0 0 8px var(--accent); }
#error { color: var(--error); flex: 1; min-width: 0; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap; }
main { flex: 1; display: grid; grid-template-columns: 1fr 320px; min-height: 0; }
#editor { background: var(--panel); border-right: 1px solid #1c1f25;
overflow: hidden; }
.cm-editor { height: 100%; font-size: 14px; }
.cm-editor.cm-focused { outline: none; }
.cm-content { font-family: inherit; }
.wave-widget { display: inline-block; vertical-align: middle; margin-left: 12px;
background: var(--wave-bg); border-radius: 3px; }
/* control surface */
#controls { padding: 16px; overflow: auto;
display: flex; flex-wrap: wrap; gap: 18px; align-content: flex-start; }
#controls.empty::before { content: 'declare faders or knobs in the patch to populate this surface →←';
color: var(--comment); font-size: 11px; line-height: 1.5;
display: block; padding: 20px 8px; }
.ctrl { display: flex; flex-direction: column; align-items: center;
gap: 6px; min-width: 70px; padding: 10px;
background: var(--panel); border: 1px solid var(--gutter);
border-radius: 6px; }
.ctrl-label { font-size: 11px; color: var(--fg); user-select: none;
max-width: 80px; overflow: hidden; text-overflow: ellipsis; }
.ctrl-value { font-size: 10px; color: var(--accent); font-variant-numeric: tabular-nums;
user-select: none; }
.knob-canvas { cursor: grab; touch-action: none; }
.knob-canvas.dragging { cursor: grabbing; }
.fader { display: flex; flex-direction: column; align-items: center; height: 140px; }
.fader-track { position: relative; width: 6px; height: 100px; background: var(--knob-track);
border-radius: 3px; cursor: grab; touch-action: none; }
.fader-track.dragging { cursor: grabbing; }
/* step sequencer */
.ctrl.stepseq { width: 100%; align-items: stretch; padding: 8px 10px; }
.stepseq-row { display: flex; gap: 2px; flex-wrap: nowrap; overflow-x: auto;
padding: 2px; touch-action: pan-x; }
.step { flex: 0 0 18px; height: 24px; border-radius: 2px;
background: #1c2026; cursor: pointer;
transition: background 60ms; }
.step:hover { background: #2a3038; }
.step.on { background: var(--accent); box-shadow: 0 0 4px var(--accent); }
.step.beat { border-top: 1px solid #3a4048; }
.step.playhead { outline: 1px solid #fff; outline-offset: -1px; }
.ctrl.stepseq .ctrl-label { align-self: flex-start; padding-left: 2px; }
/* piano roll */
.ctrl.pianoroll { width: 100%; align-items: stretch; padding: 8px 10px; gap: 4px; }
.pianoroll-grid { display: grid; grid-auto-rows: 14px; gap: 1px;
background: #0a0c10; padding: 2px; border-radius: 3px;
overflow: auto; touch-action: none; user-select: none; }
.pr-row { display: contents; }
.pr-key { background: #1a1d22; color: #aab; font-size: 9px; line-height: 14px;
padding: 0 4px; user-select: none; text-align: right; min-width: 32px; }
.pr-key.black { background: #0e1014; color: #66c; }
.pr-key.octave { background: #232730; color: var(--accent); }
.pr-cell { height: 14px; background: #1c2026; cursor: pointer;
transition: background 60ms; }
.pr-cell.row-black { background: #161a20; }
.pr-cell.beat { border-left: 1px solid #2a3038; }
.pr-cell:hover { background: #2a3038; }
.pr-cell.on { background: var(--accent); box-shadow: 0 0 3px var(--accent); }
.pr-cell.col-playhead { outline: 1px solid rgba(255,255,255,0.4); outline-offset: -1px; }
.ctrl.pianoroll .ctrl-label { align-self: flex-start; padding-left: 2px; }
.fader-fill { position: absolute; bottom: 0; left: 0; right: 0;
background: var(--accent); border-radius: 3px; }
.fader-cap { position: absolute; left: -9px; right: -9px; height: 14px;
background: var(--fg); border-radius: 2px; transform: translateY(50%); }
</style>
</head>
<body>
<header>
<button id="start">Start audio</button>
<span><span id="dot" class="dot"></span><span id="status">stopped</span></span>
<span>gain <input id="gain" type="range" min="0" max="1" step="0.01" value="0.3"></span>
<span id="info"></span>
<span id="error"></span>
</header>
<main>
<div id="editor"></div>
<div id="controls" class="empty"></div>
</main>
<script type="module">
import { EditorView, basicSetup } from "codemirror";
import { EditorState, RangeSetBuilder } from "@codemirror/state";
import { Decoration, ViewPlugin, WidgetType } from "@codemirror/view";
import { StreamLanguage, HighlightStyle, syntaxHighlighting } from "@codemirror/language";
import { tags as t } from "@lezer/highlight";
import { oneDark } from "@codemirror/theme-one-dark";
// =====================================================================
// default patch — shows osc + adsr + filter + faders/knobs declared
// =====================================================================
const DEFAULT_PATCH = `# pinta celdas en kick/hat (drum) y mel (piano). drag knobs/faders.
node tempo = knob(min=4, max=16, default=8)
node cutoff = fader(min=200, max=4000, default=900)
node res = knob(min=0.5, max=8, default=2.5)
# drum: 4-on-the-floor + off-beats
node kick = step_seq(rate=tempo, steps=16,
default=[1,0,0,0, 1,0,0,0, 1,0,0,0, 1,0,0,0])
node hat = step_seq(rate=tempo, steps=16,
default=[0,0,1,0, 0,0,1,0, 0,0,1,0, 0,0,1,0])
# bass — el kick dispara la nota
node o1 = osc(saw, freq=55)
node ke = adsr(a=0.003, d=0.25, s=0.25, r=0.2, gate=kick)
node lp = filter(lp, in=o1, cutoff=cutoff + ke*2200, q=res)
node bass = lp * ke
# hihat de noise
node n = noise()
node hp = filter(hp, in=n, cutoff=4500, q=1.5)
node he = adsr(a=0.001, d=0.04, s=0, r=0.03, gate=hat)
node hits = hp * he * 0.4
# piano roll polifonico — la melodia. dibuja notas en las celdas.
voice synth {
node o = osc(saw, freq=freq)
node e = adsr(a=0.005, d=0.2, s=0.4, r=0.3, gate=gate)
node f = filter(lp, in=o, cutoff=600 + e*1800, q=2.0)
out <- f * e
}
node mel = piano_roll(voice=synth, voices=4,
rate=tempo, length=16, octaves=2, base=220,
gate_duration=0.18)
out <- bass * 0.6 + hits + mel * 0.35
`;
// trig() doesn't accept fader directly as a Const-typed "period" if the engine wants
// a literal — but in our engine all kwargs are Node refs already. fader outputs a
// Const-buffer of its current value, so it works.
// =====================================================================
// syntax: a small StreamLanguage tokenizer for the DSL
// =====================================================================
const NODE_FNS = new Set(['osc','trig','seq','adsr','noise','filter','delay','poly','fader','knob']);
const ATOMS = new Set(['sine','saw','square','tri','lp','hp','bp']);
const KEYWORDS = new Set(['node','out','voice']);
const codeSinthLang = StreamLanguage.define({
name: 'codesinth',
startState() { return null; },
token(stream) {
if (stream.eatSpace()) return null;
if (stream.match(/#.*/)) return 'comment';
if (stream.match(/<-/)) return 'operator';
if (stream.match(/\d+\.\d+|\d+/)) return 'number';
if (stream.match(/[A-Za-z_][A-Za-z0-9_]*/)) {
const w = stream.current();
if (KEYWORDS.has(w)) return 'keyword';
if (NODE_FNS.has(w)) return 'meta';
if (ATOMS.has(w)) return 'atom';
return 'variableName';
}
if (stream.match(/[+\-*/=,()\[\]\{\}]/)) return 'operator';
stream.next();
return null;
},
});
const highlightStyle = HighlightStyle.define([
{ tag: t.keyword, color: 'var(--kw)' },
{ tag: t.atom, color: 'var(--atom)' },
{ tag: t.number, color: 'var(--num)' },
{ tag: t.comment, color: 'var(--comment)', fontStyle: 'italic' },
{ tag: t.operator, color: '#abb2bf' },
{ tag: t.meta, color: 'var(--func)' },
{ tag: t.variableName, color: 'var(--fg)' },
]);
// =====================================================================
// inline wave widgets (one canvas at end of each `node X = ...` line,
// except for control declarations whose UI lives on the right pane)
// =====================================================================
const NODE_LINE_RE = /^\s*node\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*([A-Za-z_][A-Za-z0-9_]*)?/;
const CONTROL_FNS = new Set(['fader', 'knob', 'step_seq', 'piano_roll']);
const taps = {}; // name -> Float32Array (latest snapshot from worklet)
class WaveWidget extends WidgetType {
constructor(name) { super(); this.name = name; }
eq(other) { return other.name === this.name; }
toDOM() {
const c = document.createElement('canvas');
c.className = 'wave-widget';
c.width = 220; c.height = 26;
c.dataset.tap = this.name;
return c;
}
ignoreEvent() { return true; }
}
function buildWaveDecorations(view) {
const builder = new RangeSetBuilder();
const doc = view.state.doc;
for (let i = 1; i <= doc.lines; i++) {
const line = doc.line(i);
const m = NODE_LINE_RE.exec(line.text);
if (!m) continue;
if (CONTROL_FNS.has(m[2])) continue; // controls have their own surface widget
builder.add(line.to, line.to,
Decoration.widget({ widget: new WaveWidget(m[1]), side: 1 }));
}
return builder.finish();
}
const wavePlugin = ViewPlugin.fromClass(class {
constructor(view) { this.decorations = buildWaveDecorations(view); }
update(u) { if (u.docChanged) this.decorations = buildWaveDecorations(u.view); }
}, { decorations: v => v.decorations });
// =====================================================================
// audio + worklet wiring
// =====================================================================
const startBtn = document.getElementById('start');
const dot = document.getElementById('dot');
const statusEl = document.getElementById('status');
const info = document.getElementById('info');
const errBox = document.getElementById('error');
const gainSl = document.getElementById('gain');
const ctrlBox = document.getElementById('controls');
let audioCtx = null;
let workletNode = null;
let debounceTimer = null;
let activeControls = new Map(); // name -> control widget instance (preserved across reloads)
const view = new EditorView({
doc: DEFAULT_PATCH,
extensions: [
basicSetup,
oneDark,
codeSinthLang,
syntaxHighlighting(highlightStyle),
wavePlugin,
EditorView.updateListener.of((u) => {
if (u.docChanged) {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(sendPatch, 200);
}
}),
],
parent: document.getElementById('editor'),
});
function sendPatch() {
if (!workletNode) return;
workletNode.port.postMessage({ type: 'patch', text: view.state.doc.toString() });
}
function setStatus(live, text) { dot.classList.toggle('live', live); statusEl.textContent = text; }
function setError(msg) { errBox.textContent = msg || ''; }
async function startAudio() {
if (audioCtx) return;
startBtn.disabled = true;
startBtn.textContent = 'starting…';
try {
audioCtx = new AudioContext();
await audioCtx.audioWorklet.addModule('worklet.js');
workletNode = new AudioWorkletNode(audioCtx, 'synth-engine', {
numberOfInputs: 0,
numberOfOutputs: 1,
outputChannelCount: [2],
});
workletNode.port.onmessage = (ev) => handleWorkletMsg(ev.data);
workletNode.connect(audioCtx.destination);
await audioCtx.resume();
workletNode.port.postMessage({ type: 'gain', value: parseFloat(gainSl.value) });
sendPatch();
setStatus(true, `running @ ${audioCtx.sampleRate} Hz`);
startBtn.textContent = 'stop audio';
startBtn.disabled = false;
startBtn.onclick = stopAudio;
} catch (e) {
setError(`audio init: ${e && e.message || e}`);
startBtn.textContent = 'Start audio';
startBtn.disabled = false;
audioCtx = null;
}
}
async function stopAudio() {
if (!audioCtx) return;
try { await audioCtx.close(); } catch {}
audioCtx = null; workletNode = null;
setStatus(false, 'stopped');
startBtn.textContent = 'Start audio';
startBtn.onclick = startAudio;
for (const k of Object.keys(taps)) delete taps[k];
}
function handleWorkletMsg(msg) {
if (msg.type === 'taps') {
for (const [name, arr] of Object.entries(msg.taps)) taps[name] = arr;
if (msg.playheads) {
for (const [name, idx] of Object.entries(msg.playheads)) {
const ctrl = activeControls.get(name);
if (ctrl && ctrl.kind === 'step_seq') ctrl.setPlayhead(idx);
}
}
info.textContent = `taps: ${Object.keys(msg.taps).length}`;
} else if (msg.type === 'reloaded') {
setError('');
rebuildControls(msg.controls || []);
} else if (msg.type === 'error') {
setError(msg.message);
}
}
startBtn.onclick = startAudio;
gainSl.addEventListener('input', () => {
if (workletNode) workletNode.port.postMessage({ type: 'gain', value: parseFloat(gainSl.value) });
});
// =====================================================================
// control surface: knobs + faders, populated from worklet's `reloaded`
// =====================================================================
function sendControl(name, value) {
if (!workletNode) return;
workletNode.port.postMessage({ type: 'control', name, value });
}
function makeKnob(spec) {
const wrap = document.createElement('div'); wrap.className = 'ctrl';
const canvas = document.createElement('canvas');
canvas.className = 'knob-canvas';
canvas.width = 56; canvas.height = 56;
const valEl = document.createElement('div'); valEl.className = 'ctrl-value';
const labEl = document.createElement('div'); labEl.className = 'ctrl-label';
labEl.textContent = spec.name;
wrap.appendChild(canvas); wrap.appendChild(valEl); wrap.appendChild(labEl);
const state = { ...spec, el: wrap, canvas, valEl };
function draw() {
const ctx = canvas.getContext('2d');
const w = canvas.width, h = canvas.height;
const cx = w/2, cy = h/2 + 2;
const r = Math.min(w, h) * 0.4;
const norm = (state.value - state.min) / (state.max - state.min);
const startA = Math.PI * 0.75;
const endA = Math.PI * 2.25;
const css = getComputedStyle(document.body);
ctx.clearRect(0, 0, w, h);
ctx.lineCap = 'round';
ctx.lineWidth = 4;
ctx.strokeStyle = css.getPropertyValue('--knob-track').trim();
ctx.beginPath(); ctx.arc(cx, cy, r, startA, endA); ctx.stroke();
ctx.strokeStyle = css.getPropertyValue('--accent').trim();
ctx.beginPath(); ctx.arc(cx, cy, r, startA, startA + norm * (endA - startA)); ctx.stroke();
const ang = startA + norm * (endA - startA);
ctx.strokeStyle = css.getPropertyValue('--fg').trim();
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(cx + Math.cos(ang) * r * 0.45, cy + Math.sin(ang) * r * 0.45);
ctx.lineTo(cx + Math.cos(ang) * r * 0.95, cy + Math.sin(ang) * r * 0.95);
ctx.stroke();
}
function updateLabel() { valEl.textContent = formatVal(state.value); }
function setValue(v) {
if (v < state.min) v = state.min; else if (v > state.max) v = state.max;
state.value = v; draw(); updateLabel();
}
// drag interaction: vertical drag = adjust value
canvas.addEventListener('pointerdown', (e) => {
e.preventDefault();
canvas.setPointerCapture(e.pointerId);
canvas.classList.add('dragging');
document.body.style.cursor = 'grabbing';
const startY = e.clientY;
const startV = state.value;
const span = state.max - state.min;
const onMove = (ev) => {
const dy = startY - ev.clientY; // up = positive
const factor = ev.shiftKey ? 4 : 1; // shift = fine adjust
const delta = (dy / 200) * span / factor;
setValue(startV + delta);
sendControl(state.name, state.value);
};
const onUp = () => {
canvas.classList.remove('dragging');
document.body.style.cursor = '';
canvas.removeEventListener('pointermove', onMove);
canvas.removeEventListener('pointerup', onUp);
canvas.removeEventListener('pointercancel', onUp);
};
canvas.addEventListener('pointermove', onMove);
canvas.addEventListener('pointerup', onUp);
canvas.addEventListener('pointercancel', onUp);
});
canvas.addEventListener('dblclick', () => {
setValue((state.min + state.max) / 2);
sendControl(state.name, state.value);
});
draw(); updateLabel();
return { el: wrap, setValue, getValue: () => state.value, kind: 'knob', spec: state };
}
function makeFader(spec) {
const wrap = document.createElement('div'); wrap.className = 'ctrl';
const fader = document.createElement('div'); fader.className = 'fader';
const track = document.createElement('div'); track.className = 'fader-track';
const fill = document.createElement('div'); fill.className = 'fader-fill';
const cap = document.createElement('div'); cap.className = 'fader-cap';
track.appendChild(fill); track.appendChild(cap);
fader.appendChild(track);
const valEl = document.createElement('div'); valEl.className = 'ctrl-value';
const labEl = document.createElement('div'); labEl.className = 'ctrl-label';
labEl.textContent = spec.name;
wrap.appendChild(fader); wrap.appendChild(valEl); wrap.appendChild(labEl);
const state = { ...spec };
function paint() {
const norm = (state.value - state.min) / (state.max - state.min);
const pct = Math.max(0, Math.min(1, norm));
fill.style.height = (pct * 100) + '%';
cap.style.bottom = (pct * 100) + '%';
valEl.textContent = formatVal(state.value);
}
function setValue(v) {
if (v < state.min) v = state.min; else if (v > state.max) v = state.max;
state.value = v; paint();
}
function valueAt(clientY) {
const rect = track.getBoundingClientRect();
const norm = 1 - (clientY - rect.top) / rect.height;
return state.min + Math.max(0, Math.min(1, norm)) * (state.max - state.min);
}
track.addEventListener('pointerdown', (e) => {
e.preventDefault();
track.setPointerCapture(e.pointerId);
track.classList.add('dragging');
document.body.style.cursor = 'grabbing';
setValue(valueAt(e.clientY));
sendControl(state.name, state.value);
const onMove = (ev) => { setValue(valueAt(ev.clientY)); sendControl(state.name, state.value); };
const onUp = () => {
track.classList.remove('dragging');
document.body.style.cursor = '';
track.removeEventListener('pointermove', onMove);
track.removeEventListener('pointerup', onUp);
track.removeEventListener('pointercancel', onUp);
};
track.addEventListener('pointermove', onMove);
track.addEventListener('pointerup', onUp);
track.addEventListener('pointercancel', onUp);
});
paint();
return { el: wrap, setValue, getValue: () => state.value, kind: 'fader', spec: state };
}
function formatVal(v) {
if (Math.abs(v) >= 100) return v.toFixed(0);
if (Math.abs(v) >= 10) return v.toFixed(1);
return v.toFixed(2);
}
function makeStepSeq(spec) {
const wrap = document.createElement('div');
wrap.className = 'ctrl stepseq';
const labEl = document.createElement('div'); labEl.className = 'ctrl-label';
labEl.textContent = spec.name;
const row = document.createElement('div'); row.className = 'stepseq-row';
wrap.appendChild(labEl); wrap.appendChild(row);
const state = { name: spec.name, numSteps: spec.numSteps, pattern: spec.pattern.slice(), cells: [] };
function buildCells() {
row.innerHTML = '';
state.cells = [];
for (let i = 0; i < state.numSteps; i++) {
const c = document.createElement('div');
c.className = 'step' + (i % 4 === 0 ? ' beat' : '') + (state.pattern[i] ? ' on' : '');
c.dataset.step = i;
row.appendChild(c);
state.cells.push(c);
}
}
function setPattern(arr) {
const m = Math.min(arr.length, state.numSteps);
for (let i = 0; i < m; i++) {
state.pattern[i] = arr[i] ? 1 : 0;
if (state.cells[i]) state.cells[i].classList.toggle('on', !!arr[i]);
}
}
function setNumSteps(n) {
if (n === state.numSteps) return;
const old = state.pattern;
state.pattern = new Array(n).fill(0);
const m = Math.min(old.length, n);
for (let i = 0; i < m; i++) state.pattern[i] = old[i];
state.numSteps = n;
buildCells();
}
// click + drag: paint cells (drag across to set many at once, like FL Studio)
let painting = null; // 0 or 1, the value we're painting onto cells we cross
function cellFromEvent(ev) {
const t = document.elementFromPoint(ev.clientX, ev.clientY);
if (t && t.classList && t.classList.contains('step') && t.parentNode === row) {
return parseInt(t.dataset.step, 10);
}
return -1;
}
row.addEventListener('pointerdown', (e) => {
const i = cellFromEvent(e);
if (i < 0) return;
e.preventDefault();
row.setPointerCapture(e.pointerId);
// first cell determines paint direction (toggle: paint opposite of current)
painting = state.pattern[i] ? 0 : 1;
state.pattern[i] = painting;
state.cells[i].classList.toggle('on', !!painting);
sendPattern(state.name, i, painting);
const onMove = (ev) => {
const j = cellFromEvent(ev);
if (j < 0 || state.pattern[j] === painting) return;
state.pattern[j] = painting;
state.cells[j].classList.toggle('on', !!painting);
sendPattern(state.name, j, painting);
};
const onUp = () => {
painting = null;
row.removeEventListener('pointermove', onMove);
row.removeEventListener('pointerup', onUp);
row.removeEventListener('pointercancel', onUp);
};
row.addEventListener('pointermove', onMove);
row.addEventListener('pointerup', onUp);
row.addEventListener('pointercancel', onUp);
});
buildCells();
return {
el: wrap,
kind: 'step_seq',
spec: state,
setPattern,
setNumSteps,
setPlayhead(idx) {
for (let i = 0; i < state.cells.length; i++) {
state.cells[i].classList.toggle('playhead', i === idx);
}
},
};
}
function sendPattern(name, step, value) {
if (!workletNode) return;
workletNode.port.postMessage({ type: 'pattern', name, step, value });
}
function sendNote(name, step, pitch, value) {
if (!workletNode) return;
workletNode.port.postMessage({ type: 'pattern', name, step, pitch, value });
}
// MIDI helpers — derive note names from a base frequency.
const NOTE_NAMES = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];
const BLACK_PCS = new Set([1, 3, 6, 8, 10]); // C#, D#, F#, G#, A# in pitch-class space
function freqToMidi(f) { return 69 + 12 * Math.log2(f / 440); }
function noteLabel(midi) {
const m = Math.round(midi);
return NOTE_NAMES[((m % 12) + 12) % 12] + (Math.floor(m / 12) - 1);
}
function isBlackKey(midi) { return BLACK_PCS.has(((Math.round(midi) % 12) + 12) % 12); }
function isOctaveC(midi) { return ((Math.round(midi) % 12) + 12) % 12 === 0; }
function makePianoRoll(spec) {
const wrap = document.createElement('div');
wrap.className = 'ctrl pianoroll';
const labEl = document.createElement('div'); labEl.className = 'ctrl-label';
labEl.textContent = spec.name;
const grid = document.createElement('div'); grid.className = 'pianoroll-grid';
wrap.appendChild(labEl); wrap.appendChild(grid);
const state = {
name: spec.name,
length: spec.length,
numPitches: spec.numPitches,
baseFreq: spec.baseFreq,
pattern: spec.pattern.slice(),
cells: null, // [pitch][step] -> div (pitch index 0..numPitches-1, low to high)
};
function build() {
grid.innerHTML = '';
grid.style.gridTemplateColumns = `32px repeat(${state.length}, 1fr)`;
state.cells = [];
const baseMidi = freqToMidi(state.baseFreq);
// Top-to-bottom in DOM = highest pitch first. Iterate pitches descending.
for (let p = state.numPitches - 1; p >= 0; p--) {
const midi = baseMidi + p;
const label = document.createElement('div');
label.className = 'pr-key' + (isBlackKey(midi) ? ' black' : '') + (isOctaveC(midi) ? ' octave' : '');
label.textContent = noteLabel(midi);
grid.appendChild(label);
const rowCells = [];
for (let s = 0; s < state.length; s++) {
const c = document.createElement('div');
c.className = 'pr-cell'
+ (isBlackKey(midi) ? ' row-black' : '')
+ (s % 4 === 0 ? ' beat' : '')
+ (state.pattern[s * state.numPitches + p] ? ' on' : '');
c.dataset.step = s;
c.dataset.pitch = p;
grid.appendChild(c);
rowCells.push(c);
}
state.cells[p] = rowCells;
}
}
function setCellVisual(step, pitch, on) {
const row = state.cells[pitch];
if (!row) return;
const c = row[step];
if (c) c.classList.toggle('on', !!on);
}
function setPattern(arr) {
const sz = state.length * state.numPitches;
for (let i = 0; i < sz && i < arr.length; i++) state.pattern[i] = arr[i] ? 1 : 0;
for (let p = 0; p < state.numPitches; p++)
for (let s = 0; s < state.length; s++)
setCellVisual(s, p, state.pattern[s * state.numPitches + p]);
}
function setShape(spec) {
if (spec.length === state.length && spec.numPitches === state.numPitches
&& spec.baseFreq === state.baseFreq) return false;
state.length = spec.length;
state.numPitches = spec.numPitches;
state.baseFreq = spec.baseFreq;
state.pattern = spec.pattern.slice();
build();
return true;
}
function setPlayhead(idx) {
// Toggle .col-playhead class on the column. A single column = numPitches cells.
const cells = grid.querySelectorAll('.pr-cell');
cells.forEach((c) => {
const s = +c.dataset.step;
c.classList.toggle('col-playhead', s === idx);
});
}
// drag-paint (same idea as step_seq)
let painting = null;
function cellAt(ev) {
const t = document.elementFromPoint(ev.clientX, ev.clientY);
if (t && t.classList && t.classList.contains('pr-cell') && t.parentNode === grid) {
return { step: +t.dataset.step, pitch: +t.dataset.pitch, el: t };
}
return null;
}
grid.addEventListener('pointerdown', (e) => {
const c = cellAt(e); if (!c) return;
e.preventDefault();
grid.setPointerCapture(e.pointerId);
const idx = c.step * state.numPitches + c.pitch;
painting = state.pattern[idx] ? 0 : 1;
state.pattern[idx] = painting;
c.el.classList.toggle('on', !!painting);
sendNote(state.name, c.step, c.pitch, painting);
const onMove = (ev) => {
const c2 = cellAt(ev); if (!c2) return;
const idx2 = c2.step * state.numPitches + c2.pitch;
if (state.pattern[idx2] === painting) return;
state.pattern[idx2] = painting;
c2.el.classList.toggle('on', !!painting);
sendNote(state.name, c2.step, c2.pitch, painting);
};
const onUp = () => {
painting = null;
grid.removeEventListener('pointermove', onMove);
grid.removeEventListener('pointerup', onUp);
grid.removeEventListener('pointercancel', onUp);
};
grid.addEventListener('pointermove', onMove);
grid.addEventListener('pointerup', onUp);
grid.addEventListener('pointercancel', onUp);
});
build();
return {
el: wrap,
kind: 'piano_roll',
spec: state,
setPattern,
setShape,
setPlayhead,
};
}
function rebuildControls(controls) {
// Reuse existing widgets when possible (preserve DOM focus / drag-in-progress).
const seen = new Set();
const newOrder = [];
for (const spec of controls) {
seen.add(spec.name);
let ctrl = activeControls.get(spec.name);
if (ctrl && ctrl.kind === spec.kind) {
// Update spec but don't snap visible state — the worklet preserves it.
if (spec.kind === 'step_seq') {
ctrl.setNumSteps(spec.numSteps);
ctrl.setPattern(spec.pattern);
} else if (spec.kind === 'piano_roll') {
if (!ctrl.setShape(spec)) ctrl.setPattern(spec.pattern);
} else {
ctrl.spec.min = spec.min;
ctrl.spec.max = spec.max;
ctrl.setValue(spec.value);
}
} else {
ctrl = (spec.kind === 'knob') ? makeKnob(spec)
: (spec.kind === 'fader') ? makeFader(spec)
: (spec.kind === 'step_seq') ? makeStepSeq(spec)
: (spec.kind === 'piano_roll') ? makePianoRoll(spec)
: null;
if (!ctrl) continue;
activeControls.set(spec.name, ctrl);
}
newOrder.push(ctrl);
}
for (const name of [...activeControls.keys()]) {
if (!seen.has(name)) activeControls.delete(name);
}
ctrlBox.innerHTML = '';
for (const c of newOrder) ctrlBox.appendChild(c.el);
ctrlBox.classList.toggle('empty', newOrder.length === 0);
}
// =====================================================================
// draw loop for the inline wave widgets
// =====================================================================
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);
}
}
function tick() {
document.querySelectorAll('canvas[data-tap]').forEach(drawCanvas);
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
</script>
</body>
</html>