Files
code-sinth/web/index.html
Jose Luis Montañes d0a38959c0 web: in-app SERVICE MANUAL overlay
Adds a docs overlay that lives inside the rack chrome, opened from a new
MANUAL hw-btn in the top bar (ESC also closes). Styled as a screen recess
matching the editor — amber section headers, engraved labels, recessed code
blocks — so it reads as a manual page of the instrument, not a help popup.

Covers patch language, execution model, every node type (osc, trig, adsr,
filter, noise, seq, delay, poly+voice) and the four control nodes (knob,
fader, step_seq, piano_roll), plus practical tips on gain staging and
hot-reload behavior. Each example block carries a TRY button that swaps the
editor contents with the snippet so you can hear it immediately (Ctrl/Cmd-Z
to bring your patch back).
2026-05-01 20:09:04 +02:00

1941 lines
92 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>
/* === design tokens — studio hardware emulation ===
full reference: docs/design-system.md */
:root {
--bg: #131210; /* page bg, behind the rack */
--hw-bg: #1a1916;
--hw-bg-hi: #232220;
--hw-bg-lo: #0e0d0b;
--hw-edge: #050505;
--hw-screen: #161412;
--hw-fg: #a8a39a;
--hw-fg-hi: #d8d0c0;
--hw-fg-dim: #6c6660;
--hw-engrave: #8a847a;
--hw-amber: #e8a050;
--hw-amber-hi: #f4c890;
--hw-amber-mut: rgba(232, 160, 80, 0.18);
--hw-amber-glow: rgba(232, 160, 80, 0.45);
--hw-amber-off: #28201a;
--hw-led-red: #c84838;
--hw-led-green: #6aca8a;
--hw-error: #e07060;
/* code syntax — warm desaturated palette */
--hw-syn-com: #5a554f;
--hw-syn-kw: #d68868;
--hw-syn-num: #d6a268;
--hw-syn-fn: #c8a878;
--hw-syn-id: #b8b0a0;
--hw-syn-op: #6c6660;
/* shared brushed-metal language */
--metal-specular: linear-gradient(180deg,
rgba(255, 248, 230, 0.08) 0%,
rgba(255, 248, 230, 0.03) 28%,
rgba(255, 248, 230, 0) 45%,
rgba(0, 0, 0, 0) 70%,
rgba(0, 0, 0, 0.10) 100%);
--metal-brush: repeating-linear-gradient(0deg,
rgba(255, 250, 235, 0.018) 0px,
rgba(255, 250, 235, 0.018) 1px,
rgba(0, 0, 0, 0.030) 1px,
rgba(0, 0, 0, 0.030) 2px);
--metal-bevel:
inset 0 1px 0 rgba(255, 248, 230, 0.18),
inset 0 -1px 0 rgba(0, 0, 0, 0.70),
inset 1px 0 0 rgba(255, 248, 230, 0.06),
inset -1px 0 0 rgba(0, 0, 0, 0.45);
--scrollbar-thumb: #3a342d;
--scrollbar-thumb-hover: #5a544a;
--right-w: 360px;
--splitter-w: 6px;
--t-fast: 80ms;
--t-base: 160ms;
--t-slow: 280ms;
}
/* === reset / base === */
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; background: var(--bg); color: var(--hw-fg);
font-family: 'JetBrains Mono', 'Cascadia Code', Consolas, monospace;
font-size: 12px; }
body { display: flex; flex-direction: column; padding: 8px; }
/* === scrollbars === */
* { scrollbar-width: thin; scrollbar-color: var(--scrollbar-thumb) transparent; }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb);
border-radius: 6px; border: 2px solid transparent;
background-clip: padding-box;
transition: background var(--t-base); }
::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-thumb-hover);
background-clip: padding-box; }
::-webkit-scrollbar-corner { background: transparent; }
/* ===================================================================== */
/* Outer rack — wraps the whole app */
/* ===================================================================== */
.hardware {
flex: 1; display: flex; flex-direction: column;
position: relative;
background:
var(--metal-specular),
var(--metal-brush),
linear-gradient(180deg, #181513 0%, #100c08 60%, #080604 100%);
border: 1px solid var(--hw-edge);
border-radius: 10px;
box-shadow:
var(--metal-bevel),
inset 0 0 0 1px rgba(232, 160, 80, 0.04),
0 8px 32px rgba(0, 0, 0, 0.65);
padding: 14px;
gap: 10px;
min-height: 0;
}
.screw {
position: absolute;
width: 8px; height: 8px; border-radius: 50%;
background: radial-gradient(circle at 30% 25%, #8a7d6a 0%, #3a3025 60%, #100c08 100%);
box-shadow:
inset 0 -1px 1px rgba(0,0,0,0.7),
inset 0 1px 0 rgba(255,220,180,0.18),
0 1px 1px rgba(0,0,0,0.6);
pointer-events: none; z-index: 5;
}
.screw::after {
content: ''; position: absolute; inset: 0;
background: linear-gradient(45deg, transparent 44%, rgba(0,0,0,0.55) 47%,
rgba(0,0,0,0.55) 53%, transparent 56%);
border-radius: 50%;
}
.hardware > .screw.tl { top: 8px; left: 8px; }
.hardware > .screw.tr { top: 8px; right: 8px; }
.hardware > .screw.bl { bottom: 8px; left: 8px; }
.hardware > .screw.br { bottom: 8px; right: 8px; }
/* ===================================================================== */
/* Top / bottom bars (L1) */
/* ===================================================================== */
.top-bar, .bottom-bar {
min-height: 30px;
display: flex; align-items: stretch; gap: 10px;
padding: 3px 12px;
color: var(--hw-fg-dim);
font-size: 10px; letter-spacing: 0.08em;
background:
var(--metal-specular),
var(--metal-brush),
linear-gradient(180deg, #25211d 0%, #16130f 100%);
border: 1px solid var(--hw-edge);
border-radius: 4px;
box-shadow:
var(--metal-bevel),
0 2px 4px rgba(0, 0, 0, 0.4);
flex-wrap: wrap;
}
.top-bar > *, .bottom-bar > * { align-self: center; }
.top-bar .btn-group { align-self: stretch; }
.header-sep { width: 1px; height: 16px; background: var(--hw-edge);
opacity: 0.8; align-self: center; }
.btn-group { display: flex; align-items: stretch; height: 100%; }
.btn-group .hw-btn { border-radius: 0; border-right-width: 0; }
.btn-group .hw-btn:first-child { border-radius: 3px 0 0 3px; }
.btn-group .hw-btn:last-child { border-radius: 0 3px 3px 0; border-right-width: 1px; }
.hw-btn {
background: linear-gradient(180deg, #2c261f 0%, #14110d 100%);
border: 1px solid var(--hw-edge);
color: var(--hw-fg-hi);
padding: 0 14px;
font-family: inherit; font-size: 10px;
cursor: pointer;
letter-spacing: 0.12em; text-transform: uppercase;
box-shadow:
inset 0 1px 0 rgba(255,220,180,0.10),
inset 0 -1px 0 rgba(0,0,0,0.55),
0 1px 1px rgba(0,0,0,0.4);
display: inline-flex; align-items: center; gap: 6px;
transition: color var(--t-fast);
}
.hw-btn:hover:not(:disabled) { color: var(--hw-amber); }
.hw-btn:active:not(:disabled) { transform: translateY(1px);
box-shadow: inset 0 1px 3px rgba(0,0,0,0.6); }
.hw-btn:disabled { opacity: 0.45; cursor: default; }
.hw-btn:focus-visible { outline: 1px solid var(--hw-amber-glow);
outline-offset: 2px; }
.hw-btn .led-red {
width: 6px; height: 6px; border-radius: 50%;
background: var(--hw-led-red);
box-shadow: 0 0 4px var(--hw-led-red);
}
.hw-btn .ico-play {
width: 0; height: 0;
border-left: 6px solid var(--hw-led-green);
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
filter: drop-shadow(0 0 3px var(--hw-led-green));
}
.top-bar .num { color: var(--hw-amber); font-variant-numeric: tabular-nums; }
.top-bar .right { margin-left: auto; color: var(--hw-engrave); letter-spacing: 0.18em; }
.top-bar .dot {
width: 8px; height: 8px; border-radius: 50%; background: #3a342d;
display: inline-block; vertical-align: middle; margin-right: 6px;
transition: background var(--t-base), box-shadow var(--t-base);
}
.top-bar .dot.live { background: var(--hw-amber);
animation: led-pulse 1.8s ease-in-out infinite; }
@keyframes led-pulse {
0%, 100% { box-shadow: 0 0 3px var(--hw-amber-glow); }
50% { box-shadow: 0 0 8px var(--hw-amber); }
}
/* range input — plain narrow bar with a metal cap */
input[type=range] {
-webkit-appearance: none; appearance: none;
height: 4px; background: linear-gradient(180deg, #050402, #0a0808 50%, #060403);
border-radius: 2px; vertical-align: middle;
width: 90px; cursor: pointer; outline: none;
box-shadow: inset 0 1px 2px rgba(0,0,0,0.8), inset 0 0 0 1px rgba(0,0,0,0.6);
}
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none;
width: 14px; height: 12px; border-radius: 2px;
background:
linear-gradient(180deg,
transparent 38%, rgba(0,0,0,0.45) 46%, rgba(0,0,0,0.45) 54%, transparent 62%),
linear-gradient(180deg, #b6b0a4 0%, #807a6e 50%, #3a352e 90%, #1a1612 100%);
box-shadow:
inset 0 1px 0 rgba(255,252,240,0.55),
inset 0 -1px 0 rgba(0,0,0,0.7),
0 2px 3px rgba(0,0,0,0.6);
cursor: grab;
}
input[type=range]:active::-webkit-slider-thumb { cursor: grabbing; }
input[type=range]::-moz-range-thumb {
width: 14px; height: 12px; border-radius: 2px;
background: linear-gradient(180deg, #b6b0a4 0%, #807a6e 50%, #3a352e 90%, #1a1612 100%);
border: none; cursor: grab;
}
input[type=range]:focus-visible::-webkit-slider-thumb {
box-shadow: inset 0 1px 0 rgba(255,252,240,0.55),
inset 0 -1px 0 rgba(0,0,0,0.7),
0 0 0 2px var(--hw-amber-glow);
}
#error { color: var(--hw-error); flex: 1; min-width: 0; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap; }
#info { color: var(--hw-engrave); }
/* ===================================================================== */
/* Main: screen recess | splitter | right stack */
/* ===================================================================== */
main { flex: 1; display: grid;
grid-template-columns: minmax(320px, 1fr) var(--splitter-w) minmax(260px, var(--right-w));
min-height: 0; gap: 0; }
/* code area is a recess, not a panel */
.screen {
background: var(--hw-screen);
border-radius: 4px;
padding: 8px;
box-shadow:
inset 0 2px 4px rgba(0,0,0,0.7),
inset 0 -1px 0 rgba(255,220,180,0.03),
inset 0 0 0 1px var(--hw-edge),
inset 0 6px 14px rgba(0,0,0,0.5);
overflow: hidden;
min-width: 0;
display: flex; flex-direction: column;
}
#editor { flex: 1; min-height: 0; min-width: 0; overflow: hidden; }
.cm-editor { height: 100%; font-size: 13px; background: transparent !important; }
.cm-editor.cm-focused { outline: none !important; }
.cm-editor .cm-content { font-family: inherit; caret-color: var(--hw-amber); }
.cm-editor .cm-gutters {
background: transparent !important;
border-right: 1px solid rgba(255, 220, 180, 0.04) !important;
color: var(--hw-syn-com);
}
.cm-editor .cm-activeLine,
.cm-editor .cm-activeLineGutter { background: rgba(255, 220, 180, 0.025) !important; }
.cm-editor .cm-cursor { border-left-color: var(--hw-amber) !important; }
.cm-editor .cm-selectionBackground,
.cm-editor.cm-focused .cm-selectionBackground,
.cm-editor ::selection { background: rgba(232, 160, 80, 0.20) !important; }
.cm-editor .cm-scroller { font-family: inherit; }
/* draggable seam */
.splitter { position: relative; cursor: col-resize;
background: transparent; transition: background var(--t-base); }
.splitter::before { /* widen hit area */
content: ''; position: absolute; inset: 0 -4px; z-index: 2; }
.splitter::after { /* central hairline */
content: ''; position: absolute; left: 50%; top: 14%; bottom: 14%;
width: 1px; background: rgba(0,0,0,0.6);
box-shadow: 1px 0 0 rgba(255, 220, 180, 0.04);
transform: translateX(-50%);
transition: background var(--t-base), box-shadow var(--t-base);
}
.splitter:hover::after,
.splitter.dragging::after { background: var(--hw-amber);
box-shadow: 0 0 6px var(--hw-amber-glow); }
/* inline wave widget after each `node X = ...` line */
.wave-widget { display: inline-block; vertical-align: middle; margin-left: 12px;
background: #0a0908; border-radius: 2px;
box-shadow: inset 0 1px 2px rgba(0,0,0,0.7),
inset 0 0 0 1px var(--hw-edge); }
/* ===================================================================== */
/* Right pane — sub-panels grouped by control kind */
/* ===================================================================== */
#controls { padding: 0; overflow: auto;
display: flex; flex-direction: column; gap: 8px;
min-width: 0; min-height: 0; }
#controls.empty::before {
content: 'declare faders, knobs, step_seq, or piano_roll in the patch';
color: var(--hw-engrave); font-size: 10px; line-height: 1.5;
letter-spacing: 0.08em; display: block; padding: 16px 12px;
border: 1px dashed rgba(255, 220, 180, 0.10); border-radius: 4px;
}
/* shared L2 sub-panel */
.panel {
border: 1px solid var(--hw-edge);
border-radius: 6px;
background:
var(--metal-specular),
var(--metal-brush),
linear-gradient(180deg, #2c2823 0%, #1a1612 55%, #110d09 100%);
box-shadow:
var(--metal-bevel),
inset 0 -10px 14px -10px rgba(255, 220, 180, 0.10),
0 2px 4px rgba(0, 0, 0, 0.45),
0 6px 14px rgba(0, 0, 0, 0.30);
}
/* knobs panel: 2-col grid */
.panel.knobs {
padding: 14px 12px;
display: grid; grid-template-columns: 1fr 1fr; gap: 12px 10px;
}
.knob-cell {
display: flex; flex-direction: column; align-items: center; gap: 4px;
}
.knob-cell .label {
font-size: 9px; letter-spacing: 0.16em; text-transform: uppercase;
color: var(--hw-engrave);
text-shadow: 0 1px 0 rgba(0,0,0,0.6), 0 -1px 0 rgba(255,220,180,0.04);
user-select: none; max-width: 72px; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap;
}
.knob-cell .value {
font-size: 10px; font-variant-numeric: tabular-nums;
color: var(--hw-fg-hi); letter-spacing: 0.05em;
min-width: 46px; text-align: center; user-select: none;
}
.hw-knob { cursor: grab; touch-action: none; }
.hw-knob.dragging { cursor: grabbing; }
/* faders panel: flex row */
.panel.faders {
padding: 10px 10px 14px;
display: flex; justify-content: space-around; align-items: flex-start;
gap: 8px; flex-wrap: wrap;
}
.fader-cell {
display: flex; flex-direction: column; align-items: center;
gap: 4px; flex: 0 1 auto; min-width: 36px;
}
.fader-cell .label {
font-size: 8px; letter-spacing: 0.18em; text-transform: uppercase;
color: var(--hw-engrave);
text-shadow: 0 1px 0 rgba(0,0,0,0.7), 0 -1px 0 rgba(255,220,180,0.04);
user-select: none; max-width: 58px; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap;
}
.fd-track {
position: relative;
width: 5px; height: 100px;
background: linear-gradient(180deg, #050402 0%, #0a0808 50%, #060403 100%);
border-radius: 3px;
box-shadow:
inset 0 1px 3px rgba(0,0,0,0.85),
inset 0 0 0 1px rgba(0,0,0,0.6),
inset 0 -1px 0 rgba(255,220,180,0.05),
0 1px 0 rgba(255,220,180,0.04);
cursor: pointer; touch-action: none;
}
.fd-ticks {
position: absolute; top: 4px; bottom: 4px;
width: 16px; pointer-events: none;
background: repeating-linear-gradient(180deg,
rgba(255,220,180,0.20) 0px,
rgba(255,220,180,0.20) 1px,
transparent 1px,
transparent 7px);
}
.fd-ticks.left { left: -18px; }
.fd-ticks.right { right: -18px; }
.fd-cap {
position: absolute; left: 50%; transform: translate(-50%, -50%);
width: 26px; height: 14px; border-radius: 2px;
background:
linear-gradient(180deg,
transparent 38%, rgba(0,0,0,0.55) 46%, rgba(0,0,0,0.55) 54%,
transparent 62%),
repeating-linear-gradient(0deg,
rgba(255,250,235,0.04) 0px, rgba(255,250,235,0.04) 1px,
rgba(0,0,0,0.05) 1px, rgba(0,0,0,0.05) 2px),
linear-gradient(180deg, #b6b0a4 0%, #807a6e 45%, #3a352e 80%, #1a1612 100%);
box-shadow:
inset 0 1px 0 rgba(255,252,240,0.55),
inset 0 -1px 0 rgba(0,0,0,0.7),
inset 1px 0 0 rgba(255,250,235,0.15),
inset -1px 0 0 rgba(0,0,0,0.45),
0 2px 3px rgba(0,0,0,0.6);
pointer-events: none;
}
.fd-track.dragging { cursor: grabbing; }
.fader-cell .value {
font-size: 10px; font-variant-numeric: tabular-nums;
color: var(--hw-fg-hi); letter-spacing: 0.05em; margin-top: 2px;
}
/* sequencer panel: stack of seq-rows */
.panel.seq {
padding: 8px 10px;
display: flex; flex-direction: column; gap: 5px;
}
.seq-row { display: flex; align-items: center; gap: 8px; }
.seq-row .label {
width: 56px; flex: 0 0 56px;
font-size: 8px; letter-spacing: 0.16em; text-transform: uppercase;
color: var(--hw-engrave);
text-shadow: 0 1px 0 rgba(0,0,0,0.6);
user-select: none; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap;
}
.leds {
display: flex; gap: 2px; flex: 1; min-width: 0; flex-wrap: nowrap;
overflow-x: auto; touch-action: pan-x;
}
.led {
flex: 1 1 0; min-width: 8px; height: 14px; border-radius: 1px;
background: linear-gradient(180deg, #0a0805 0%, var(--hw-amber-off) 100%);
border: 1px solid var(--hw-edge);
box-shadow: inset 0 1px 1px rgba(0,0,0,0.7);
cursor: pointer; position: relative;
transition: background 60ms;
}
.led:hover { background: linear-gradient(180deg, #181208 0%, #382818 100%); }
.led.on {
background: radial-gradient(circle at 50% 25%,
var(--hw-amber-hi) 0%, var(--hw-amber) 55%, #a06820 100%);
box-shadow: inset 0 0 2px rgba(255,255,255,0.4),
0 0 5px var(--hw-amber-glow);
}
.led.beat::before {
content: ''; position: absolute; left: -1px; top: -3px; bottom: -3px;
width: 1px; background: var(--hw-amber-mut);
}
.led.playhead {
outline: 1px solid rgba(232, 160, 80, 0.6);
outline-offset: 1px;
}
/* piano roll panel: full-width grid */
.panel.pianoroll {
padding: 8px 10px;
display: flex; flex-direction: column; gap: 5px;
}
.panel.pianoroll > .label {
font-size: 8px; letter-spacing: 0.16em; text-transform: uppercase;
color: var(--hw-engrave);
text-shadow: 0 1px 0 rgba(0,0,0,0.6);
user-select: none; padding-left: 2px;
}
.pianoroll-grid {
display: grid; grid-auto-rows: 14px; gap: 1px;
background: #0a0805;
padding: 2px; border-radius: 3px;
box-shadow: inset 0 1px 2px rgba(0,0,0,0.8), inset 0 0 0 1px var(--hw-edge);
overflow: auto; touch-action: none; user-select: none;
}
.pr-key {
background: linear-gradient(180deg, #2a2620 0%, #14110d 100%);
color: var(--hw-fg-dim); font-size: 9px; line-height: 14px;
padding: 0 4px; user-select: none; text-align: right; min-width: 32px;
box-shadow: inset 0 -1px 0 rgba(0,0,0,0.6);
}
.pr-key.black { background: linear-gradient(180deg, #14110d 0%, #060403 100%);
color: var(--hw-syn-com); }
.pr-key.octave { color: var(--hw-amber); }
.pr-cell { height: 14px; background: #100c08; cursor: pointer;
transition: background 60ms; }
.pr-cell.row-black { background: #0a0805; }
.pr-cell.beat { box-shadow: inset 1px 0 0 var(--hw-amber-mut); }
.pr-cell:hover { background: #2a1f12; }
.pr-cell.on {
background: radial-gradient(circle at 50% 25%,
var(--hw-amber-hi) 0%, var(--hw-amber) 55%, #a06820 100%);
box-shadow: inset 0 0 2px rgba(255,255,255,0.4),
0 0 3px var(--hw-amber-glow);
}
.pr-cell.col-playhead { outline: 1px solid rgba(232, 160, 80, 0.5);
outline-offset: -1px; }
/* bottom bar */
.running-label {
color: var(--hw-amber); font-size: 10px; letter-spacing: 0.18em;
text-shadow: 0 0 5px var(--hw-amber-glow);
}
.running-dot {
display: inline-block;
width: 6px; height: 6px; border-radius: 50%;
background: var(--hw-amber);
box-shadow: 0 0 5px var(--hw-amber-glow);
margin-right: 6px; vertical-align: middle;
animation: led-pulse 2.2s ease-in-out infinite;
}
.running-label.idle { color: var(--hw-engrave); text-shadow: none; }
.running-label.idle .running-dot { background: #3a342d;
box-shadow: none; animation: none; }
.blink { animation: blink 1.1s step-end infinite; }
@keyframes blink { 50% { opacity: 0; } }
/* ===================================================================== */
/* Manual overlay — in-app docs styled as a "service manual" */
/* Sits inside .hardware, between top-bar and bottom-bar. */
/* ===================================================================== */
#manual {
position: absolute;
/* sit between the top-bar and bottom-bar (each 30px + 10px gap = 40px). */
top: calc(14px + 30px + 10px);
left: 14px; right: 14px;
bottom: calc(14px + 30px + 10px);
z-index: 10;
display: none;
flex-direction: column;
background: var(--hw-screen);
border-radius: 4px;
box-shadow:
inset 0 2px 4px rgba(0,0,0,0.7),
inset 0 -1px 0 rgba(255,220,180,0.03),
inset 0 0 0 1px var(--hw-edge),
inset 0 6px 14px rgba(0,0,0,0.5);
overflow: hidden;
}
#manual.open { display: flex; }
.manual-head {
display: flex; align-items: center; gap: 12px;
padding: 10px 14px 8px;
border-bottom: 1px solid rgba(255,220,180,0.06);
flex: 0 0 auto;
}
.manual-head .title {
color: var(--hw-amber); font-size: 11px; letter-spacing: 0.22em;
text-transform: uppercase;
text-shadow: 0 0 5px var(--hw-amber-glow);
}
.manual-head .sub {
color: var(--hw-engrave); font-size: 9px; letter-spacing: 0.18em;
text-transform: uppercase; margin-left: auto;
}
.manual-close {
background: linear-gradient(180deg, #2c261f 0%, #14110d 100%);
border: 1px solid var(--hw-edge);
color: var(--hw-fg-hi); cursor: pointer;
width: 22px; height: 22px; border-radius: 3px;
font-family: inherit; font-size: 14px; line-height: 1;
box-shadow: inset 0 1px 0 rgba(255,220,180,0.10),
inset 0 -1px 0 rgba(0,0,0,0.55),
0 1px 1px rgba(0,0,0,0.4);
display: inline-flex; align-items: center; justify-content: center;
transition: color var(--t-fast);
}
.manual-close:hover { color: var(--hw-amber); }
.manual-body {
flex: 1; min-height: 0; overflow: auto;
padding: 14px 22px 24px;
color: var(--hw-fg);
font-size: 12px; line-height: 1.6;
max-width: 880px; margin: 0 auto;
}
.manual-body h2 {
color: var(--hw-amber);
font-size: 11px; letter-spacing: 0.20em;
text-transform: uppercase;
margin: 22px 0 8px;
padding-bottom: 4px;
border-bottom: 1px solid rgba(232, 160, 80, 0.22);
text-shadow: 0 0 4px var(--hw-amber-glow);
}
.manual-body h2:first-child { margin-top: 0; }
.manual-body h3 {
color: var(--hw-fg-hi); font-size: 11px;
letter-spacing: 0.10em; text-transform: uppercase;
margin: 14px 0 4px;
}
.manual-body p { margin: 6px 0 10px; color: var(--hw-fg); }
.manual-body code {
color: var(--hw-syn-num);
background: rgba(0,0,0,0.40);
padding: 1px 5px; border-radius: 2px;
font-size: 11px;
box-shadow: inset 0 1px 1px rgba(0,0,0,0.4);
}
.manual-body pre {
margin: 8px 0 14px;
background: linear-gradient(180deg, #0a0805 0%, #060403 100%);
border-radius: 3px;
padding: 10px 12px;
color: var(--hw-fg);
font-size: 11px; line-height: 1.55;
overflow-x: auto;
box-shadow: inset 0 1px 3px rgba(0,0,0,0.85),
inset 0 0 0 1px var(--hw-edge);
position: relative;
}
.manual-body pre .syn-com { color: var(--hw-syn-com); font-style: italic; }
.manual-body pre .syn-kw { color: var(--hw-syn-kw); }
.manual-body pre .syn-num { color: var(--hw-syn-num); }
.manual-body pre .syn-fn { color: var(--hw-syn-fn); }
.manual-body pre .syn-id { color: var(--hw-syn-id); }
.manual-body pre .syn-op { color: var(--hw-syn-op); }
.manual-body pre .syn-arrow { color: var(--hw-amber); }
.manual-body pre .try-btn {
position: absolute; top: 6px; right: 6px;
background: linear-gradient(180deg, #2c261f 0%, #14110d 100%);
color: var(--hw-fg-dim); border: 1px solid var(--hw-edge);
padding: 2px 8px; border-radius: 2px;
font-family: inherit; font-size: 9px;
letter-spacing: 0.12em; text-transform: uppercase;
cursor: pointer;
box-shadow: inset 0 1px 0 rgba(255,220,180,0.08),
inset 0 -1px 0 rgba(0,0,0,0.55);
transition: color var(--t-fast);
}
.manual-body pre .try-btn:hover { color: var(--hw-amber); }
.manual-body table {
border-collapse: collapse; margin: 8px 0 14px;
font-size: 11px; width: 100%;
}
.manual-body th, .manual-body td {
border-bottom: 1px solid rgba(255,220,180,0.06);
padding: 5px 8px; text-align: left; vertical-align: top;
}
.manual-body th {
color: var(--hw-engrave);
font-weight: normal; letter-spacing: 0.12em;
text-transform: uppercase; font-size: 9px;
}
.manual-body td code { font-size: 10px; }
.manual-body ul { margin: 4px 0 10px; padding-left: 18px; }
.manual-body li { margin: 2px 0; }
.manual-body em { color: var(--hw-amber); font-style: normal; }
.manual-body a { color: var(--hw-amber); text-decoration: none;
border-bottom: 1px dashed rgba(232,160,80,0.4); }
.manual-body a:hover { border-bottom-style: solid; }
.manual-toc {
display: flex; flex-wrap: wrap; gap: 4px 14px;
margin: 0 0 18px; padding: 8px 12px;
background: rgba(0,0,0,0.35);
border-radius: 3px;
box-shadow: inset 0 1px 2px rgba(0,0,0,0.7);
font-size: 10px; letter-spacing: 0.10em;
}
.manual-toc a { color: var(--hw-fg-dim); border-bottom: none;
text-transform: uppercase; }
.manual-toc a:hover { color: var(--hw-amber); }
</style>
</head>
<body>
<div class="hardware">
<span class="screw tl"></span>
<span class="screw tr"></span>
<span class="screw bl"></span>
<span class="screw br"></span>
<div class="top-bar">
<div class="btn-group">
<button class="hw-btn" id="start"><span class="ico-play"></span><span id="start-label">RUN</span></button>
<button class="hw-btn" id="manual-toggle" title="Manual (?)">MANUAL</button>
</div>
<span><span id="dot" class="dot"></span><span id="status">stopped</span></span>
<span class="header-sep"></span>
<span>GAIN <input id="gain" type="range" min="0" max="1" step="0.01" value="0.3"></span>
<span class="header-sep"></span>
<span id="info"></span>
<span id="error"></span>
<span class="right">CODE · SINTH</span>
</div>
<main>
<section class="screen">
<div id="editor"></div>
</section>
<div class="splitter" id="splitter" title="drag to resize · double-click to reset"></div>
<div id="controls" class="empty"></div>
</main>
<div class="bottom-bar">
<span class="running-label idle" id="running"><span class="running-dot"></span><span id="running-text">IDLE</span><span class="blink">_</span></span>
</div>
<!-- ===================== manual overlay ===================== -->
<section id="manual" aria-hidden="true">
<div class="manual-head">
<span class="title">SERVICE MANUAL</span>
<span class="sub">code · sinth · v1</span>
<button class="manual-close" id="manual-close" title="Close (Esc)">×</button>
</div>
<div class="manual-body">
<p>code-sinth es un sintetizador modular gobernado por un pequeño lenguaje
de patches. El editor de la izquierda es la fuente de verdad: cada
línea <code>node X = ...</code> declara un nodo del grafo, y
<code>out &lt;- ...</code> elige qué señal sale por los altavoces.
Cualquier cambio en el editor se recarga en caliente preservando el
estado interno (fases de osciladores, posición de secuenciadores,
envolventes en vuelo).</p>
<div class="manual-toc">
<a href="#manual-language">Lenguaje</a>
<a href="#manual-flow">Cómo se ejecuta</a>
<a href="#manual-osc">Osciladores</a>
<a href="#manual-envelopes">ADSR / Trig</a>
<a href="#manual-filter">Filter</a>
<a href="#manual-noise">Noise</a>
<a href="#manual-seq">Seq</a>
<a href="#manual-delay">Delay</a>
<a href="#manual-poly">Poly · Voices</a>
<a href="#manual-controls">Knobs · Faders · Pads</a>
<a href="#manual-tips">Consejos</a>
</div>
<h2 id="manual-language">Lenguaje del patch</h2>
<p>El patch es texto plano. Las construcciones son cuatro:</p>
<ul>
<li><code>node nombre = expresión</code> — declara un nodo con nombre.
Puedes referirlo por su nombre en cualquier expresión posterior.</li>
<li><code>out &lt;- expresión</code> — la señal que se manda al
<em>output</em>. Debe haber exactamente una.</li>
<li><code>voice nombre { ... }</code> — bloque que define una
<em>plantilla de voz</em> reutilizable. Dentro tiene su propio
<code>node</code> y un <code>out &lt;-</code> propio. Se instancia
desde <code>poly(voice=nombre, voices=N, ...)</code>.</li>
<li><code># comentario</code> — hasta fin de línea.</li>
</ul>
<p>Las expresiones admiten números (<code>0.5</code>, <code>440</code>),
identificadores, <code>+ - * /</code>, paréntesis, listas
<code>[1, 0, 1, 0]</code> y llamadas a funciones-nodo. Los argumentos
pueden ser posicionales o con nombre: <code>osc(saw, freq=220)</code>.
Las señales se mezclan sumándolas: <code>out &lt;- a*0.5 + b*0.3</code>.
Multiplicar por una envolvente actúa como amplificador controlado:
<code>o1 * env</code>.</p>
<h2 id="manual-flow">Cómo se ejecuta</h2>
<p>El motor corre en un <em>AudioWorklet</em> y procesa bloques de 128
muestras a la sample-rate del navegador (típicamente 48 kHz). En cada
bloque se evalúa el grafo en orden topológico: cada nodo lee los
buffers de sus entradas, produce su buffer de salida, y al final el
buffer de <code>out</code> sale por los altavoces.</p>
<p>Pulsa <em>RUN</em> para arrancar el audio (los navegadores requieren
un click del usuario antes de abrir el AudioContext). El indicador
<em>RUNNING_</em> abajo se enciende cuando el motor está procesando.</p>
<h2 id="manual-osc">Osciladores · <code>osc</code></h2>
<p>Genera una forma de onda continua a la frecuencia indicada.
<code>freq</code> puede ser un número fijo o cualquier señal
(otro nodo) — eso da modulación.</p>
<pre data-snippet="node lfo = osc(sine, freq=4)
node pit = 220 + lfo*30
node main = osc(saw, freq=pit)
out &lt;- main*0.4"><span class="syn-kw">node</span> <span class="syn-id">lfo</span> <span class="syn-op">=</span> <span class="syn-fn">osc</span><span class="syn-op">(</span><span class="syn-id">sine</span><span class="syn-op">,</span> <span class="syn-id">freq</span><span class="syn-op">=</span><span class="syn-num">4</span><span class="syn-op">)</span> <span class="syn-com"># 4 Hz</span>
<span class="syn-kw">node</span> <span class="syn-id">pit</span> <span class="syn-op">=</span> <span class="syn-num">220</span> <span class="syn-op">+</span> <span class="syn-id">lfo</span><span class="syn-op">*</span><span class="syn-num">30</span> <span class="syn-com"># vibrato +/-30 Hz</span>
<span class="syn-kw">node</span> <span class="syn-id">main</span> <span class="syn-op">=</span> <span class="syn-fn">osc</span><span class="syn-op">(</span><span class="syn-id">saw</span><span class="syn-op">,</span> <span class="syn-id">freq</span><span class="syn-op">=</span><span class="syn-id">pit</span><span class="syn-op">)</span>
<span class="syn-kw">out</span> <span class="syn-arrow">&lt;-</span> <span class="syn-id">main</span><span class="syn-op">*</span><span class="syn-num">0.4</span></pre>
<table>
<tr><th>Forma</th><th>Carácter</th><th>Uso típico</th></tr>
<tr><td><code>sine</code></td><td>limpio, sin armónicos</td><td>LFO, sub-bass, pads</td></tr>
<tr><td><code>saw</code></td><td>brillante, todos los armónicos</td><td>leads, bajos, strings</td></tr>
<tr><td><code>square</code></td><td>solo armónicos impares</td><td>chiptune, bajos huecos</td></tr>
<tr><td><code>tri</code></td><td>cálido, armónicos impares decrecientes</td><td>flautas, sub-leads</td></tr>
</table>
<h2 id="manual-envelopes">Envolventes · <code>adsr</code> y <code>trig</code></h2>
<p><code>trig(period, duration)</code> emite una <em>compuerta</em>
(gate) cíclica: alta durante <code>duration</code> segundos, luego
baja, repitiéndose cada <code>period</code> segundos.</p>
<p><code>adsr(a, d, s, r, gate)</code> es la envolvente clásica.
<code>a/d/r</code> en segundos, <code>s</code> nivel 0..1.
Mientras <code>gate</code> esté en 1 sube por la fase de attack,
cae al sustain y se queda; cuando vuelve a 0 entra en release.</p>
<pre data-snippet="node g1 = trig(period=0.5, duration=0.05)
node env = adsr(a=0.005, d=0.1, s=0.0, r=0.2, gate=g1)
node tone = osc(sine, freq=440)
out &lt;- tone * env"><span class="syn-kw">node</span> <span class="syn-id">g1</span> <span class="syn-op">=</span> <span class="syn-fn">trig</span><span class="syn-op">(</span><span class="syn-id">period</span><span class="syn-op">=</span><span class="syn-num">0.5</span><span class="syn-op">,</span> <span class="syn-id">duration</span><span class="syn-op">=</span><span class="syn-num">0.05</span><span class="syn-op">)</span>
<span class="syn-kw">node</span> <span class="syn-id">env</span> <span class="syn-op">=</span> <span class="syn-fn">adsr</span><span class="syn-op">(</span><span class="syn-id">a</span><span class="syn-op">=</span><span class="syn-num">0.005</span><span class="syn-op">,</span> <span class="syn-id">d</span><span class="syn-op">=</span><span class="syn-num">0.1</span><span class="syn-op">,</span> <span class="syn-id">s</span><span class="syn-op">=</span><span class="syn-num">0.0</span><span class="syn-op">,</span> <span class="syn-id">r</span><span class="syn-op">=</span><span class="syn-num">0.2</span><span class="syn-op">,</span> <span class="syn-id">gate</span><span class="syn-op">=</span><span class="syn-id">g1</span><span class="syn-op">)</span>
<span class="syn-kw">node</span> <span class="syn-id">tone</span> <span class="syn-op">=</span> <span class="syn-fn">osc</span><span class="syn-op">(</span><span class="syn-id">sine</span><span class="syn-op">,</span> <span class="syn-id">freq</span><span class="syn-op">=</span><span class="syn-num">440</span><span class="syn-op">)</span>
<span class="syn-kw">out</span> <span class="syn-arrow">&lt;-</span> <span class="syn-id">tone</span> <span class="syn-op">*</span> <span class="syn-id">env</span></pre>
<p>Truco: para una <em>percusión</em>, pon <code>s=0</code> y
<code>d</code> corto (~0.1 s). Para un <em>pad</em>, sube
<code>a</code> a 0.52 s y deja <code>s</code> alto.</p>
<h2 id="manual-filter">Filtro · <code>filter</code></h2>
<p>Biquad RBJ, tipos <code>lp</code> (low-pass), <code>hp</code>
(high-pass) o <code>bp</code> (band-pass). <code>cutoff</code> y
<code>q</code> (resonancia) pueden modularse a tasa de audio —
sumarle una envolvente al cutoff es la receta clásica del bajo
sintetizado.</p>
<pre data-snippet="node src = osc(saw, freq=110)
node g = trig(period=0.5, duration=0.2)
node e = adsr(a=0.005, d=0.2, s=0.0, r=0.1, gate=g)
node lp = filter(lp, in=src, cutoff=300 + e*2500, q=4)
out &lt;- lp * e"><span class="syn-kw">node</span> <span class="syn-id">src</span> <span class="syn-op">=</span> <span class="syn-fn">osc</span><span class="syn-op">(</span><span class="syn-id">saw</span><span class="syn-op">,</span> <span class="syn-id">freq</span><span class="syn-op">=</span><span class="syn-num">110</span><span class="syn-op">)</span>
<span class="syn-kw">node</span> <span class="syn-id">g</span> <span class="syn-op">=</span> <span class="syn-fn">trig</span><span class="syn-op">(</span><span class="syn-id">period</span><span class="syn-op">=</span><span class="syn-num">0.5</span><span class="syn-op">,</span> <span class="syn-id">duration</span><span class="syn-op">=</span><span class="syn-num">0.2</span><span class="syn-op">)</span>
<span class="syn-kw">node</span> <span class="syn-id">e</span> <span class="syn-op">=</span> <span class="syn-fn">adsr</span><span class="syn-op">(</span><span class="syn-id">a</span><span class="syn-op">=</span><span class="syn-num">0.005</span><span class="syn-op">,</span> <span class="syn-id">d</span><span class="syn-op">=</span><span class="syn-num">0.2</span><span class="syn-op">,</span> <span class="syn-id">s</span><span class="syn-op">=</span><span class="syn-num">0</span><span class="syn-op">,</span> <span class="syn-id">r</span><span class="syn-op">=</span><span class="syn-num">0.1</span><span class="syn-op">,</span> <span class="syn-id">gate</span><span class="syn-op">=</span><span class="syn-id">g</span><span class="syn-op">)</span>
<span class="syn-kw">node</span> <span class="syn-id">lp</span> <span class="syn-op">=</span> <span class="syn-fn">filter</span><span class="syn-op">(</span><span class="syn-id">lp</span><span class="syn-op">,</span> <span class="syn-id">in</span><span class="syn-op">=</span><span class="syn-id">src</span><span class="syn-op">,</span> <span class="syn-id">cutoff</span><span class="syn-op">=</span><span class="syn-num">300</span> <span class="syn-op">+</span> <span class="syn-id">e</span><span class="syn-op">*</span><span class="syn-num">2500</span><span class="syn-op">,</span> <span class="syn-id">q</span><span class="syn-op">=</span><span class="syn-num">4</span><span class="syn-op">)</span>
<span class="syn-kw">out</span> <span class="syn-arrow">&lt;-</span> <span class="syn-id">lp</span> <span class="syn-op">*</span> <span class="syn-id">e</span></pre>
<h2 id="manual-noise">Ruido · <code>noise</code></h2>
<p>Ruido blanco en [-1, 1]. Filtrado con <code>hp</code> alto y una
envolvente cortita, da un hi-hat. Filtrado con <code>bp</code> con
cutoff bajo, una caja.</p>
<pre data-snippet="node n = noise()
node g = trig(period=0.25, duration=0.02)
node e = adsr(a=0.001, d=0.04, s=0.0, r=0.03, gate=g)
node hp = filter(hp, in=n, cutoff=5000, q=1.5)
out &lt;- hp * e * 0.4"><span class="syn-kw">node</span> <span class="syn-id">n</span> <span class="syn-op">=</span> <span class="syn-fn">noise</span><span class="syn-op">()</span>
<span class="syn-kw">node</span> <span class="syn-id">g</span> <span class="syn-op">=</span> <span class="syn-fn">trig</span><span class="syn-op">(</span><span class="syn-id">period</span><span class="syn-op">=</span><span class="syn-num">0.25</span><span class="syn-op">,</span> <span class="syn-id">duration</span><span class="syn-op">=</span><span class="syn-num">0.02</span><span class="syn-op">)</span>
<span class="syn-kw">node</span> <span class="syn-id">e</span> <span class="syn-op">=</span> <span class="syn-fn">adsr</span><span class="syn-op">(</span><span class="syn-id">a</span><span class="syn-op">=</span><span class="syn-num">0.001</span><span class="syn-op">,</span> <span class="syn-id">d</span><span class="syn-op">=</span><span class="syn-num">0.04</span><span class="syn-op">,</span> <span class="syn-id">s</span><span class="syn-op">=</span><span class="syn-num">0</span><span class="syn-op">,</span> <span class="syn-id">r</span><span class="syn-op">=</span><span class="syn-num">0.03</span><span class="syn-op">,</span> <span class="syn-id">gate</span><span class="syn-op">=</span><span class="syn-id">g</span><span class="syn-op">)</span>
<span class="syn-kw">node</span> <span class="syn-id">hp</span> <span class="syn-op">=</span> <span class="syn-fn">filter</span><span class="syn-op">(</span><span class="syn-id">hp</span><span class="syn-op">,</span> <span class="syn-id">in</span><span class="syn-op">=</span><span class="syn-id">n</span><span class="syn-op">,</span> <span class="syn-id">cutoff</span><span class="syn-op">=</span><span class="syn-num">5000</span><span class="syn-op">,</span> <span class="syn-id">q</span><span class="syn-op">=</span><span class="syn-num">1.5</span><span class="syn-op">)</span>
<span class="syn-kw">out</span> <span class="syn-arrow">&lt;-</span> <span class="syn-id">hp</span> <span class="syn-op">*</span> <span class="syn-id">e</span> <span class="syn-op">*</span> <span class="syn-num">0.4</span></pre>
<h2 id="manual-seq">Secuenciador básico · <code>seq</code></h2>
<p>Recorre una lista de valores a <code>rate</code> pasos por segundo,
manteniendo cada valor hasta el siguiente. Útil para listas de
frecuencias o de niveles de control.</p>
<pre data-snippet="node freqs = seq(rate=4, steps=[220, 277, 330, 220])
node tone = osc(saw, freq=freqs)
node g = trig(period=0.25, duration=0.18)
node e = adsr(a=0.005, d=0.1, s=0.6, r=0.2, gate=g)
out &lt;- tone * e * 0.4"><span class="syn-kw">node</span> <span class="syn-id">freqs</span> <span class="syn-op">=</span> <span class="syn-fn">seq</span><span class="syn-op">(</span><span class="syn-id">rate</span><span class="syn-op">=</span><span class="syn-num">4</span><span class="syn-op">,</span> <span class="syn-id">steps</span><span class="syn-op">=[</span><span class="syn-num">220</span><span class="syn-op">,</span> <span class="syn-num">277</span><span class="syn-op">,</span> <span class="syn-num">330</span><span class="syn-op">,</span> <span class="syn-num">220</span><span class="syn-op">])</span>
<span class="syn-kw">node</span> <span class="syn-id">tone</span> <span class="syn-op">=</span> <span class="syn-fn">osc</span><span class="syn-op">(</span><span class="syn-id">saw</span><span class="syn-op">,</span> <span class="syn-id">freq</span><span class="syn-op">=</span><span class="syn-id">freqs</span><span class="syn-op">)</span>
<span class="syn-kw">node</span> <span class="syn-id">g</span> <span class="syn-op">=</span> <span class="syn-fn">trig</span><span class="syn-op">(</span><span class="syn-id">period</span><span class="syn-op">=</span><span class="syn-num">0.25</span><span class="syn-op">,</span> <span class="syn-id">duration</span><span class="syn-op">=</span><span class="syn-num">0.18</span><span class="syn-op">)</span>
<span class="syn-kw">node</span> <span class="syn-id">e</span> <span class="syn-op">=</span> <span class="syn-fn">adsr</span><span class="syn-op">(</span><span class="syn-id">a</span><span class="syn-op">=</span><span class="syn-num">0.005</span><span class="syn-op">,</span> <span class="syn-id">d</span><span class="syn-op">=</span><span class="syn-num">0.1</span><span class="syn-op">,</span> <span class="syn-id">s</span><span class="syn-op">=</span><span class="syn-num">0.6</span><span class="syn-op">,</span> <span class="syn-id">r</span><span class="syn-op">=</span><span class="syn-num">0.2</span><span class="syn-op">,</span> <span class="syn-id">gate</span><span class="syn-op">=</span><span class="syn-id">g</span><span class="syn-op">)</span>
<span class="syn-kw">out</span> <span class="syn-arrow">&lt;-</span> <span class="syn-id">tone</span> <span class="syn-op">*</span> <span class="syn-id">e</span> <span class="syn-op">*</span> <span class="syn-num">0.4</span></pre>
<h2 id="manual-delay">Delay · <code>delay</code></h2>
<p>Línea de delay con feedback. <code>time</code> en segundos,
<code>feedback</code> 0..0.99, <code>mix</code> 0..1
(0 = solo seco, 1 = solo retardado).</p>
<pre data-snippet="node src = osc(tri, freq=330)
node g = trig(period=1.0, duration=0.05)
node e = adsr(a=0.005, d=0.2, s=0, r=0.1, gate=g)
node dry = src * e
node dl = delay(in=dry, time=0.375, feedback=0.55, mix=0.5)
out &lt;- dl * 0.5"><span class="syn-kw">node</span> <span class="syn-id">src</span> <span class="syn-op">=</span> <span class="syn-fn">osc</span><span class="syn-op">(</span><span class="syn-id">tri</span><span class="syn-op">,</span> <span class="syn-id">freq</span><span class="syn-op">=</span><span class="syn-num">330</span><span class="syn-op">)</span>
<span class="syn-kw">node</span> <span class="syn-id">g</span> <span class="syn-op">=</span> <span class="syn-fn">trig</span><span class="syn-op">(</span><span class="syn-id">period</span><span class="syn-op">=</span><span class="syn-num">1.0</span><span class="syn-op">,</span> <span class="syn-id">duration</span><span class="syn-op">=</span><span class="syn-num">0.05</span><span class="syn-op">)</span>
<span class="syn-kw">node</span> <span class="syn-id">e</span> <span class="syn-op">=</span> <span class="syn-fn">adsr</span><span class="syn-op">(</span><span class="syn-id">a</span><span class="syn-op">=</span><span class="syn-num">0.005</span><span class="syn-op">,</span> <span class="syn-id">d</span><span class="syn-op">=</span><span class="syn-num">0.2</span><span class="syn-op">,</span> <span class="syn-id">s</span><span class="syn-op">=</span><span class="syn-num">0</span><span class="syn-op">,</span> <span class="syn-id">r</span><span class="syn-op">=</span><span class="syn-num">0.1</span><span class="syn-op">,</span> <span class="syn-id">gate</span><span class="syn-op">=</span><span class="syn-id">g</span><span class="syn-op">)</span>
<span class="syn-kw">node</span> <span class="syn-id">dry</span> <span class="syn-op">=</span> <span class="syn-id">src</span> <span class="syn-op">*</span> <span class="syn-id">e</span>
<span class="syn-kw">node</span> <span class="syn-id">dl</span> <span class="syn-op">=</span> <span class="syn-fn">delay</span><span class="syn-op">(</span><span class="syn-id">in</span><span class="syn-op">=</span><span class="syn-id">dry</span><span class="syn-op">,</span> <span class="syn-id">time</span><span class="syn-op">=</span><span class="syn-num">0.375</span><span class="syn-op">,</span> <span class="syn-id">feedback</span><span class="syn-op">=</span><span class="syn-num">0.55</span><span class="syn-op">,</span> <span class="syn-id">mix</span><span class="syn-op">=</span><span class="syn-num">0.5</span><span class="syn-op">)</span>
<span class="syn-kw">out</span> <span class="syn-arrow">&lt;-</span> <span class="syn-id">dl</span> <span class="syn-op">*</span> <span class="syn-num">0.5</span></pre>
<h2 id="manual-poly">Polifonía · <code>voice</code> + <code>poly</code></h2>
<p>Define una <em>plantilla de voz</em> con un bloque
<code>voice nombre { ... }</code>: dentro tendrás dos identificadores
especiales — <code>freq</code> (la frecuencia que el allocator
asigna a la voz) y <code>gate</code> (la compuerta que se abre
durante <code>gate_duration</code> segundos por cada nota).</p>
<p>Luego instancia con <code>poly(voice=nombre, voices=N, rate, notes,
gate_duration)</code>. <code>notes</code> es una lista de frecuencias;
<code>0</code> es silencio. El allocator asigna la voz menos reciente
(LRU) a cada nota nueva.</p>
<pre data-snippet="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 &lt;- f * e
}
node mel = poly(voice=synth, voices=4, rate=4, gate_duration=0.18,
notes=[220, 277, 330, 0, 220, 330, 277, 0])
out &lt;- mel * 0.4"><span class="syn-kw">voice</span> <span class="syn-id">synth</span> <span class="syn-op">{</span>
<span class="syn-kw">node</span> <span class="syn-id">o</span> <span class="syn-op">=</span> <span class="syn-fn">osc</span><span class="syn-op">(</span><span class="syn-id">saw</span><span class="syn-op">,</span> <span class="syn-id">freq</span><span class="syn-op">=</span><span class="syn-id">freq</span><span class="syn-op">)</span>
<span class="syn-kw">node</span> <span class="syn-id">e</span> <span class="syn-op">=</span> <span class="syn-fn">adsr</span><span class="syn-op">(</span><span class="syn-id">a</span><span class="syn-op">=</span><span class="syn-num">0.005</span><span class="syn-op">,</span> <span class="syn-id">d</span><span class="syn-op">=</span><span class="syn-num">0.2</span><span class="syn-op">,</span> <span class="syn-id">s</span><span class="syn-op">=</span><span class="syn-num">0.4</span><span class="syn-op">,</span> <span class="syn-id">r</span><span class="syn-op">=</span><span class="syn-num">0.3</span><span class="syn-op">,</span> <span class="syn-id">gate</span><span class="syn-op">=</span><span class="syn-id">gate</span><span class="syn-op">)</span>
<span class="syn-kw">node</span> <span class="syn-id">f</span> <span class="syn-op">=</span> <span class="syn-fn">filter</span><span class="syn-op">(</span><span class="syn-id">lp</span><span class="syn-op">,</span> <span class="syn-id">in</span><span class="syn-op">=</span><span class="syn-id">o</span><span class="syn-op">,</span> <span class="syn-id">cutoff</span><span class="syn-op">=</span><span class="syn-num">600</span> <span class="syn-op">+</span> <span class="syn-id">e</span><span class="syn-op">*</span><span class="syn-num">1800</span><span class="syn-op">,</span> <span class="syn-id">q</span><span class="syn-op">=</span><span class="syn-num">2.0</span><span class="syn-op">)</span>
<span class="syn-kw">out</span> <span class="syn-arrow">&lt;-</span> <span class="syn-id">f</span> <span class="syn-op">*</span> <span class="syn-id">e</span>
<span class="syn-op">}</span>
<span class="syn-kw">node</span> <span class="syn-id">mel</span> <span class="syn-op">=</span> <span class="syn-fn">poly</span><span class="syn-op">(</span><span class="syn-id">voice</span><span class="syn-op">=</span><span class="syn-id">synth</span><span class="syn-op">,</span> <span class="syn-id">voices</span><span class="syn-op">=</span><span class="syn-num">4</span><span class="syn-op">,</span> <span class="syn-id">rate</span><span class="syn-op">=</span><span class="syn-num">4</span><span class="syn-op">,</span> <span class="syn-id">gate_duration</span><span class="syn-op">=</span><span class="syn-num">0.18</span><span class="syn-op">,</span>
<span class="syn-id">notes</span><span class="syn-op">=[</span><span class="syn-num">220</span><span class="syn-op">,</span> <span class="syn-num">277</span><span class="syn-op">,</span> <span class="syn-num">330</span><span class="syn-op">,</span> <span class="syn-num">0</span><span class="syn-op">,</span> <span class="syn-num">220</span><span class="syn-op">,</span> <span class="syn-num">330</span><span class="syn-op">,</span> <span class="syn-num">277</span><span class="syn-op">,</span> <span class="syn-num">0</span><span class="syn-op">])</span>
<span class="syn-kw">out</span> <span class="syn-arrow">&lt;-</span> <span class="syn-id">mel</span> <span class="syn-op">*</span> <span class="syn-num">0.4</span></pre>
<h2 id="manual-controls">Knobs · faders · pads</h2>
<p>Hay cuatro tipos de nodo cuyo valor lo decide el panel derecho en
vez del código. Al declararlos aparece un widget físico al que
puedes interactuar con el ratón.</p>
<h3>Knob</h3>
<pre data-snippet="node cutoff = knob(min=200, max=4000, default=900)"><span class="syn-kw">node</span> <span class="syn-id">cutoff</span> <span class="syn-op">=</span> <span class="syn-fn">knob</span><span class="syn-op">(</span><span class="syn-id">min</span><span class="syn-op">=</span><span class="syn-num">200</span><span class="syn-op">,</span> <span class="syn-id">max</span><span class="syn-op">=</span><span class="syn-num">4000</span><span class="syn-op">,</span> <span class="syn-id">default</span><span class="syn-op">=</span><span class="syn-num">900</span><span class="syn-op">)</span></pre>
<p>Arrastra arriba/abajo para girarlo, doble-click para volver al
centro, <em>Shift</em> + arrastrar para ajuste fino.</p>
<h3>Fader</h3>
<pre data-snippet="node mix = fader(min=0, max=1, default=0.5)"><span class="syn-kw">node</span> <span class="syn-id">mix</span> <span class="syn-op">=</span> <span class="syn-fn">fader</span><span class="syn-op">(</span><span class="syn-id">min</span><span class="syn-op">=</span><span class="syn-num">0</span><span class="syn-op">,</span> <span class="syn-id">max</span><span class="syn-op">=</span><span class="syn-num">1</span><span class="syn-op">,</span> <span class="syn-id">default</span><span class="syn-op">=</span><span class="syn-num">0.5</span><span class="syn-op">)</span></pre>
<h3>Step sequencer</h3>
<pre data-snippet="node kick = step_seq(rate=8, steps=16,
default=[1,0,0,0, 1,0,0,0, 1,0,0,0, 1,0,0,0])"><span class="syn-kw">node</span> <span class="syn-id">kick</span> <span class="syn-op">=</span> <span class="syn-fn">step_seq</span><span class="syn-op">(</span><span class="syn-id">rate</span><span class="syn-op">=</span><span class="syn-num">8</span><span class="syn-op">,</span> <span class="syn-id">steps</span><span class="syn-op">=</span><span class="syn-num">16</span><span class="syn-op">,</span>
<span class="syn-id">default</span><span class="syn-op">=[</span><span class="syn-num">1</span><span class="syn-op">,</span><span class="syn-num">0</span><span class="syn-op">,</span><span class="syn-num">0</span><span class="syn-op">,</span><span class="syn-num">0</span><span class="syn-op">,</span> <span class="syn-num">1</span><span class="syn-op">,</span><span class="syn-num">0</span><span class="syn-op">,</span><span class="syn-num">0</span><span class="syn-op">,</span><span class="syn-num">0</span><span class="syn-op">,</span> <span class="syn-num">1</span><span class="syn-op">,</span><span class="syn-num">0</span><span class="syn-op">,</span><span class="syn-num">0</span><span class="syn-op">,</span><span class="syn-num">0</span><span class="syn-op">,</span> <span class="syn-num">1</span><span class="syn-op">,</span><span class="syn-num">0</span><span class="syn-op">,</span><span class="syn-num">0</span><span class="syn-op">,</span><span class="syn-num">0</span><span class="syn-op">])</span></pre>
<p>Click en una celda para encender/apagar; arrastra para pintar
muchas a la vez. La salida es 0/1, perfecta como
<code>gate=</code> de un ADSR.</p>
<h3>Piano roll</h3>
<pre data-snippet="node mel = piano_roll(voice=synth, voices=4,
rate=8, length=16, octaves=2, base=220,
gate_duration=0.18)"><span class="syn-kw">node</span> <span class="syn-id">mel</span> <span class="syn-op">=</span> <span class="syn-fn">piano_roll</span><span class="syn-op">(</span><span class="syn-id">voice</span><span class="syn-op">=</span><span class="syn-id">synth</span><span class="syn-op">,</span> <span class="syn-id">voices</span><span class="syn-op">=</span><span class="syn-num">4</span><span class="syn-op">,</span>
<span class="syn-id">rate</span><span class="syn-op">=</span><span class="syn-num">8</span><span class="syn-op">,</span> <span class="syn-id">length</span><span class="syn-op">=</span><span class="syn-num">16</span><span class="syn-op">,</span> <span class="syn-id">octaves</span><span class="syn-op">=</span><span class="syn-num">2</span><span class="syn-op">,</span> <span class="syn-id">base</span><span class="syn-op">=</span><span class="syn-num">220</span><span class="syn-op">,</span>
<span class="syn-id">gate_duration</span><span class="syn-op">=</span><span class="syn-num">0.18</span><span class="syn-op">)</span></pre>
<p>Es un <code>poly()</code> con las notas dispuestas en una rejilla
de pasos × semitonos. Click en cualquier celda para colocar una
nota. La columna 0 es <code>base</code> Hz, cada fila es un
semitono más arriba. Necesita un <code>voice</code> previo, igual
que <code>poly</code>.</p>
<h2 id="manual-tips">Consejos</h2>
<ul>
<li><em>Empieza bajo.</em> El gain del header es 0.30 por algo:
sumar varias señales sin escalar satura rápido.</li>
<li><em>Multiplica por la envolvente al final.</em>
<code>filter(...) * env</code> evita clics al inicio/final
mejor que poner la envolvente solo en el cutoff.</li>
<li><em>Hot-reload preserva el estado.</em> Los osciladores no
saltan de fase, los secuenciadores no resetean. Borra y reescribe
el nodo si quieres reiniciarlo.</li>
<li><em>El waveform inline después de cada nodo</em> es lo que está
saliendo realmente del grafo en ese punto. Útil para ver dónde
se está rompiendo el sonido.</li>
<li><em>Para mezclar varias fuentes</em>, súmalas con
<code>+</code> y multiplica el total por un factor pequeño:
<code>out &lt;- bass*0.6 + drum*0.5 + lead*0.4</code>.</li>
</ul>
<p style="color: var(--hw-engrave); font-size: 10px; margin-top: 28px;
letter-spacing: 0.18em; text-align: center; text-transform: uppercase;">
— fin del manual —
</p>
</div>
</section>
</div>
<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(--hw-syn-kw)' },
{ tag: t.atom, color: 'var(--hw-syn-fn)' },
{ tag: t.number, color: 'var(--hw-syn-num)' },
{ tag: t.comment, color: 'var(--hw-syn-com)', fontStyle: 'italic' },
{ tag: t.operator, color: 'var(--hw-syn-op)' },
{ tag: t.meta, color: 'var(--hw-syn-fn)' },
{ tag: t.variableName, color: 'var(--hw-syn-id)' },
]);
// =====================================================================
// 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 startLabel = document.getElementById('start-label');
const playIco = startBtn.querySelector('.ico-play');
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');
const runEl = document.getElementById('running');
const runText = document.getElementById('running-text');
function setRunningUI(running) {
runEl.classList.toggle('idle', !running);
runText.textContent = running ? 'RUNNING' : 'IDLE';
startLabel.textContent = running ? 'STOP' : 'RUN';
// swap the icon: green play triangle when stopped, red led when running
playIco.classList.toggle('led-red', running);
playIco.classList.toggle('ico-play', !running);
}
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;
setRunningUI(live);
}
function setError(msg) { errBox.textContent = msg || ''; }
async function startAudio() {
if (audioCtx) return;
startBtn.disabled = true;
startLabel.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.disabled = false;
startBtn.onclick = stopAudio;
} catch (e) {
setError(`audio init: ${e && e.message || e}`);
setRunningUI(false);
startBtn.disabled = false;
audioCtx = null;
}
}
async function stopAudio() {
if (!audioCtx) return;
try { await audioCtx.close(); } catch {}
audioCtx = null; workletNode = null;
setStatus(false, 'stopped');
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 });
}
// =====================================================================
// knob — 8-layer canvas drawing (studio hardware emulation).
// Canvas is intentionally larger than the visible disc so the amber
// arc and drop shadow have margin to render without clipping.
// =====================================================================
const KNOB_SIZE = 72;
function setupCanvas(canvas, size) {
const dpr = window.devicePixelRatio || 1;
canvas.style.width = size + 'px';
canvas.style.height = size + 'px';
canvas.width = Math.round(size * dpr);
canvas.height = Math.round(size * dpr);
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
return ctx;
}
function drawKnob(ctx, size, norm) {
const cx = size / 2, cy = size / 2;
const totalR = 26;
const rimW = 2.5;
const discR = totalR - rimW;
ctx.clearRect(0, 0, size, size);
// (1) drop shadow
ctx.save();
ctx.shadowColor = 'rgba(0,0,0,0.7)';
ctx.shadowBlur = 6;
ctx.shadowOffsetY = 2;
ctx.fillStyle = '#000';
ctx.beginPath();
ctx.arc(cx, cy, totalR + 0.5, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
// (2) amber value arc (faint full track + bright active)
const startA = Math.PI * 0.78, endA = Math.PI * 2.22;
ctx.lineCap = 'round';
ctx.lineWidth = 1.5;
ctx.strokeStyle = 'rgba(232,160,80,0.10)';
ctx.beginPath();
ctx.arc(cx, cy, totalR + 4, startA, endA);
ctx.stroke();
ctx.strokeStyle = '#e8a050';
ctx.shadowBlur = 3;
ctx.shadowColor = 'rgba(232,160,80,0.55)';
ctx.beginPath();
ctx.arc(cx, cy, totalR + 4, startA, startA + norm * (endA - startA));
ctx.stroke();
ctx.shadowBlur = 0;
// (3) dark rim
const rim = ctx.createLinearGradient(0, cy - totalR, 0, cy + totalR);
rim.addColorStop(0, '#2a2826');
rim.addColorStop(0.5, '#0a0908');
rim.addColorStop(1, '#040404');
ctx.strokeStyle = rim;
ctx.lineWidth = rimW;
ctx.beginPath();
ctx.arc(cx, cy, totalR - rimW / 2, 0, Math.PI * 2);
ctx.stroke();
// (4a) metallic disc — base radial
const baseGrad = ctx.createRadialGradient(cx, cy, 0, cx, cy, discR);
baseGrad.addColorStop(0, '#787268');
baseGrad.addColorStop(0.7, '#5a544c');
baseGrad.addColorStop(1, '#36322d');
ctx.fillStyle = baseGrad;
ctx.beginPath();
ctx.arc(cx, cy, discR, 0, Math.PI * 2);
ctx.fill();
// (4b) concentric brushing — lathe-turned aluminium feel
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, discR - 0.3, 0, Math.PI * 2);
ctx.clip();
for (let r = discR; r > 0.5; r -= 0.55) {
const seed = Math.floor(r * 17.31);
const n = ((seed * 9301 + 49297) % 233) / 233;
const a = 0.04 + n * 0.10;
ctx.strokeStyle = n > 0.55
? `rgba(255, 245, 225, ${a.toFixed(3)})`
: `rgba(10, 8, 6, ${(a * 0.85).toFixed(3)})`;
ctx.lineWidth = 0.5;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.stroke();
}
ctx.restore();
// (4c) vertical lighting — light from above
const vert = ctx.createLinearGradient(0, cy - discR, 0, cy + discR);
vert.addColorStop(0, 'rgba(255, 248, 230, 0.32)');
vert.addColorStop(0.40, 'rgba(255, 248, 230, 0.04)');
vert.addColorStop(0.55, 'rgba(0, 0, 0, 0.05)');
vert.addColorStop(1, 'rgba(0, 0, 0, 0.45)');
ctx.fillStyle = vert;
ctx.beginPath();
ctx.arc(cx, cy, discR, 0, Math.PI * 2);
ctx.fill();
// (4d) horizontal specular band on the upper third
const band = ctx.createLinearGradient(0, cy - discR * 0.65, 0, cy - discR * 0.05);
band.addColorStop(0, 'rgba(255, 252, 240, 0)');
band.addColorStop(0.5, 'rgba(255, 252, 240, 0.42)');
band.addColorStop(1, 'rgba(255, 252, 240, 0)');
ctx.fillStyle = band;
ctx.beginPath();
ctx.arc(cx, cy, discR, 0, Math.PI * 2);
ctx.fill();
// (4e) bottom rim light — softbox bounce
const rimLight = ctx.createLinearGradient(0, cy + discR * 0.55, 0, cy + discR);
rimLight.addColorStop(0, 'rgba(255, 230, 200, 0)');
rimLight.addColorStop(1, 'rgba(255, 230, 200, 0.14)');
ctx.fillStyle = rimLight;
ctx.beginPath();
ctx.arc(cx, cy, discR, 0, Math.PI * 2);
ctx.fill();
// (5) bevel — bright top crescent
ctx.strokeStyle = 'rgba(255, 252, 240, 0.55)';
ctx.lineWidth = 0.9;
ctx.beginPath();
ctx.arc(cx, cy, discR - 0.5, Math.PI * 1.10, Math.PI * 1.90);
ctx.stroke();
// (5b) bevel — dark bottom crescent
ctx.strokeStyle = 'rgba(0, 0, 0, 0.50)';
ctx.lineWidth = 0.9;
ctx.beginPath();
ctx.arc(cx, cy, discR - 0.5, Math.PI * 0.10, Math.PI * 0.90);
ctx.stroke();
// (6) inset shadow line where disc meets rim
ctx.strokeStyle = 'rgba(0, 0, 0, 0.65)';
ctx.lineWidth = 0.7;
ctx.beginPath();
ctx.arc(cx, cy, discR + 0.3, 0, Math.PI * 2);
ctx.stroke();
// (7) indicator — engraved tapered notch
const ang = startA + norm * (endA - startA);
const tipR = discR * 0.86;
const baseR = discR * 0.50;
const perp = ang + Math.PI / 2;
const half = 1.5;
const tipX = cx + Math.cos(ang) * tipR;
const tipY = cy + Math.sin(ang) * tipR;
const bX = cx + Math.cos(ang) * baseR;
const bY = cy + Math.sin(ang) * baseR;
ctx.fillStyle = 'rgba(6, 5, 4, 0.92)';
ctx.beginPath();
ctx.moveTo(tipX, tipY);
ctx.lineTo(bX + Math.cos(perp) * half, bY + Math.sin(perp) * half);
ctx.lineTo(bX - Math.cos(perp) * half, bY - Math.sin(perp) * half);
ctx.closePath();
ctx.fill();
ctx.strokeStyle = 'rgba(255, 250, 230, 0.25)';
ctx.lineWidth = 0.4;
ctx.beginPath();
ctx.moveTo(bX + Math.cos(perp) * half, bY + Math.sin(perp) * half);
ctx.lineTo(tipX, tipY);
ctx.stroke();
}
function makeKnob(spec) {
const wrap = document.createElement('div'); wrap.className = 'knob-cell';
const labEl = document.createElement('span'); labEl.className = 'label';
labEl.textContent = spec.name;
const canvas = document.createElement('canvas'); canvas.className = 'hw-knob';
const valEl = document.createElement('span'); valEl.className = 'value';
wrap.appendChild(labEl); wrap.appendChild(canvas); wrap.appendChild(valEl);
const ctx = setupCanvas(canvas, KNOB_SIZE);
const state = { ...spec, el: wrap, canvas, valEl };
function draw() {
const norm = Math.max(0, Math.min(1, (state.value - state.min) / (state.max - state.min)));
drawKnob(ctx, KNOB_SIZE, norm);
}
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();
}
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;
const factor = ev.shiftKey ? 4 : 1;
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 = 'fader-cell';
const labEl = document.createElement('span'); labEl.className = 'label';
labEl.textContent = spec.name;
const track = document.createElement('div'); track.className = 'fd-track';
const ticksL = document.createElement('div'); ticksL.className = 'fd-ticks left';
const ticksR = document.createElement('div'); ticksR.className = 'fd-ticks right';
const cap = document.createElement('div'); cap.className = 'fd-cap';
track.appendChild(ticksL); track.appendChild(ticksR); track.appendChild(cap);
const valEl = document.createElement('span'); valEl.className = 'value';
wrap.appendChild(labEl); wrap.appendChild(track); wrap.appendChild(valEl);
const state = { ...spec };
function paint() {
const norm = Math.max(0, Math.min(1, (state.value - state.min) / (state.max - state.min)));
const trackH = track.clientHeight;
// cap is positioned by top + transform: translateY(-50%) (set in CSS)
cap.style.top = ((1 - norm) * trackH) + 'px';
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);
});
// Layout might not be ready (cap.style.top depends on track.clientHeight).
// Defer first paint until after layout.
requestAnimationFrame(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 = 'seq-row';
const labEl = document.createElement('span'); labEl.className = 'label';
labEl.textContent = spec.name;
const row = document.createElement('div'); row.className = 'leds';
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 = 'led' + (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 (FL Studio style)
let painting = null;
function cellFromEvent(ev) {
const t = document.elementFromPoint(ev.clientX, ev.clientY);
if (t && t.classList && t.classList.contains('led') && 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);
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) {
// pianoroll IS its own panel — large enough that grouping with others
// would be cramped.
const wrap = document.createElement('section');
wrap.className = 'panel pianoroll';
const labEl = document.createElement('span'); labEl.className = '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 byKind = { knob: [], fader: [], step_seq: [], piano_roll: [] };
for (const spec of controls) {
seen.add(spec.name);
let ctrl = activeControls.get(spec.name);
if (ctrl && ctrl.kind === spec.kind) {
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);
}
if (byKind[spec.kind]) byKind[spec.kind].push(ctrl);
}
for (const name of [...activeControls.keys()]) {
if (!seen.has(name)) activeControls.delete(name);
}
// Render: detach all cells from any prior parent, then group by kind into
// shared sub-panels. Reusing the same widget instances keeps drag state.
ctrlBox.innerHTML = '';
function appendPanel(kind, cls) {
if (!byKind[kind].length) return;
const panel = document.createElement('section');
panel.className = 'panel ' + cls;
for (const c of byKind[kind]) panel.appendChild(c.el);
ctrlBox.appendChild(panel);
}
appendPanel('knob', 'knobs');
appendPanel('fader', 'faders');
appendPanel('step_seq','seq');
// pianorolls are each their own panel — append directly
for (const c of byKind.piano_roll) ctrlBox.appendChild(c.el);
const total = byKind.knob.length + byKind.fader.length
+ byKind.step_seq.length + byKind.piano_roll.length;
ctrlBox.classList.toggle('empty', total === 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;
ctx.clearRect(0, 0, w, h); // CSS background of .wave-widget shows through
if (!samples || samples.length === 0) {
ctx.fillStyle = 'rgba(232, 160, 80, 0.25)';
ctx.font = '9px 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.shadowColor = 'rgba(232, 160, 80, 0.45)';
ctx.shadowBlur = 2;
ctx.strokeStyle = '#e8a050';
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();
ctx.shadowBlur = 0;
if (peak < 0.5 && peak > 0) {
ctx.fillStyle = 'rgba(232, 160, 80, 0.45)';
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);
// =====================================================================
// splitter — drag the seam between editor and surface to rebalance.
// width persists in localStorage; double-click resets to default.
// =====================================================================
{
const splitter = document.getElementById('splitter');
const root = document.documentElement;
const KEY = 'cs.right-w';
const MIN_LEFT = 320;
const MIN_RIGHT = 260;
try {
const saved = localStorage.getItem(KEY);
if (saved) root.style.setProperty('--right-w', saved);
} catch {}
splitter.addEventListener('pointerdown', (e) => {
e.preventDefault();
splitter.setPointerCapture(e.pointerId);
splitter.classList.add('dragging');
document.body.style.cursor = 'col-resize';
const onMove = (ev) => {
const winW = root.clientWidth;
const want = winW - ev.clientX;
const w = Math.max(MIN_RIGHT, Math.min(winW - MIN_LEFT, want));
root.style.setProperty('--right-w', w + 'px');
};
const onUp = () => {
splitter.classList.remove('dragging');
document.body.style.cursor = '';
splitter.removeEventListener('pointermove', onMove);
splitter.removeEventListener('pointerup', onUp);
splitter.removeEventListener('pointercancel', onUp);
try {
const w = getComputedStyle(root).getPropertyValue('--right-w').trim();
if (w) localStorage.setItem(KEY, w);
} catch {}
};
splitter.addEventListener('pointermove', onMove);
splitter.addEventListener('pointerup', onUp);
splitter.addEventListener('pointercancel', onUp);
});
splitter.addEventListener('dblclick', () => {
root.style.removeProperty('--right-w');
try { localStorage.removeItem(KEY); } catch {}
});
}
// =====================================================================
// manual — in-app docs overlay. Toggle button in top bar, ESC closes.
// Each <pre data-snippet="..."> in the manual gets a TRY button that
// replaces the editor doc with that snippet (Ctrl/Cmd-Z to undo).
// =====================================================================
{
const manual = document.getElementById('manual');
const toggle = document.getElementById('manual-toggle');
const closeBtn = document.getElementById('manual-close');
function open() { manual.classList.add('open'); manual.setAttribute('aria-hidden', 'false'); }
function close() { manual.classList.remove('open'); manual.setAttribute('aria-hidden', 'true'); }
toggle.addEventListener('click', () => {
if (manual.classList.contains('open')) close(); else open();
});
closeBtn.addEventListener('click', close);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && manual.classList.contains('open')) close();
});
// Inject TRY buttons on every snippet block.
for (const pre of manual.querySelectorAll('pre[data-snippet]')) {
const btn = document.createElement('button');
btn.className = 'try-btn';
btn.textContent = 'TRY';
btn.title = 'Replace the editor with this snippet (Ctrl/Cmd-Z to undo)';
btn.addEventListener('click', () => {
const snippet = pre.getAttribute('data-snippet');
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: snippet + '\n' },
});
close();
view.focus();
});
pre.appendChild(btn);
}
}
</script>
</body>
</html>