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).
1941 lines
92 KiB
HTML
1941 lines
92 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>
|
||
/* === 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 <- ...</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 <- 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 <-</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 <- 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 <- 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"><-</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 <- 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"><-</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.5–2 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 <- 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"><-</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 <- 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"><-</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 <- 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"><-</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 <- 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"><-</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 <- 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 <- 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"><-</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"><-</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 <- 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>
|