The amber arc was drawn at radius ~30px from center on a 56×56 canvas — the bottom of the arc (at angles near 6 o'clock) sat at y ≈ 58 and got clipped by the canvas's own bounding box. Visible as a flat cut-off at extreme positions. Fix: bump the canvas to 72×72 and use fixed-pixel values for the knob's geometry instead of fractions of `size`, so the visible knob diameter stays at ~52px regardless of canvas size and there's now ~10px of margin all around for the arc and drop shadow. - KNOB_SIZE: 56 → 72 - totalR: size * 0.46 → 26 (fixed) - rimW: size * 0.045 → 2.5 (fixed) - notch half-base: size * 0.026 → 1.5 (fixed) - cy: size/2 + 1 → size/2 (no need for the offset on the larger canvas) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
875 lines
31 KiB
HTML
875 lines
31 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>code-sinth — studio hardware emulation</title>
|
|
<style>
|
|
:root {
|
|
--bg: #131210;
|
|
--hw-bg: #1a1916; /* panel: neutral dark, very faint warm tint */
|
|
--hw-bg-hi: #232220;
|
|
--hw-bg-lo: #0e0d0b;
|
|
--hw-edge: #050505;
|
|
--hw-screw: #5a5550;
|
|
--hw-amber: #e8a050; /* used ONLY for arcs / LEDs / labels */
|
|
--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-fg: #a8a39a; /* general text: warm-neutral cream */
|
|
--hw-fg-hi: #d8d0c0;
|
|
--hw-fg-dim: #6c6660;
|
|
--hw-engrave: #8a847a;
|
|
--hw-screen: #161412;
|
|
--hw-led-red: #c84838;
|
|
--hw-led-green: #6aca8a;
|
|
--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 for all rack frames + sub-panels */
|
|
--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);
|
|
}
|
|
* { 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 { padding: 32px; min-height: 100vh; display: flex; align-items: flex-start; justify-content: center; }
|
|
|
|
/* ===================================================================== */
|
|
/* Outer layout: sidebar (description) + hardware unit */
|
|
/* ===================================================================== */
|
|
.layout {
|
|
width: 100%;
|
|
max-width: 980px;
|
|
}
|
|
|
|
/* ===================================================================== */
|
|
/* Hardware unit — single large rounded panel containing everything */
|
|
/* ===================================================================== */
|
|
.hardware {
|
|
position: relative;
|
|
/* darker base than the sub-panels so they stand out against it */
|
|
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),
|
|
0 1px 0 rgba(255, 220, 180, 0.04);
|
|
padding: 14px;
|
|
display: grid;
|
|
grid-template-columns: 1fr 220px;
|
|
gap: 12px;
|
|
}
|
|
.left-stack { display: grid; grid-template-rows: auto 1fr auto; gap: 8px; min-width: 0; }
|
|
.right-stack { display: grid; grid-template-rows: auto auto auto; gap: 8px; min-width: 0; }
|
|
|
|
/* corner screws — only on the outermost frame */
|
|
.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; }
|
|
|
|
/* ===================================================================== */
|
|
/* Header bar inside the hardware unit */
|
|
/* ===================================================================== */
|
|
.top-bar, .bottom-bar {
|
|
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);
|
|
}
|
|
.top-bar > *, .bottom-bar > * { align-self: center; }
|
|
.top-bar .btn-group { align-self: stretch; }
|
|
|
|
.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 80ms;
|
|
}
|
|
.hw-btn:hover { color: var(--hw-amber); }
|
|
.hw-btn:active { transform: translateY(1px);
|
|
box-shadow: inset 0 1px 3px rgba(0,0,0,0.6); }
|
|
.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);
|
|
animation: led-pulse 2.2s ease-in-out infinite;
|
|
}
|
|
.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));
|
|
}
|
|
@keyframes led-pulse {
|
|
0%, 100% { box-shadow: 0 0 3px var(--hw-led-red); }
|
|
50% { box-shadow: 0 0 8px var(--hw-led-red); }
|
|
}
|
|
.hw-header .num { color: var(--hw-amber); font-variant-numeric: tabular-nums; }
|
|
.hw-header .right { margin-left: auto; color: var(--hw-engrave); letter-spacing: 0.18em; }
|
|
|
|
/* ===================================================================== */
|
|
/* Code "screen" — left, big */
|
|
/* ===================================================================== */
|
|
/* the code area is *recessed* into the rack — no separate bordered panel.
|
|
Achieved with deep inset shadow + slightly darker bg than the rack. */
|
|
.screen {
|
|
background: var(--hw-screen);
|
|
border-radius: 4px;
|
|
padding: 12px 18px;
|
|
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);
|
|
display: flex; flex-direction: column;
|
|
min-height: 300px;
|
|
overflow: hidden;
|
|
}
|
|
.glass {
|
|
flex: 1; overflow: auto;
|
|
font-size: 12px; line-height: 19px;
|
|
}
|
|
.code-line { display: flex; gap: 14px; white-space: pre; }
|
|
.ln { color: var(--hw-syn-com); user-select: none; min-width: 16px; text-align: right; }
|
|
.syn-com { color: var(--hw-syn-com); font-style: italic; }
|
|
.syn-kw { color: var(--hw-syn-kw); }
|
|
.syn-num { color: var(--hw-syn-num); }
|
|
.syn-fn { color: var(--hw-syn-fn); }
|
|
.syn-id { color: var(--hw-syn-id); }
|
|
.syn-op { color: var(--hw-syn-op); }
|
|
.syn-arrow { color: var(--hw-amber); }
|
|
|
|
.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;
|
|
}
|
|
.blink { animation: blink 1.1s step-end infinite; }
|
|
@keyframes blink { 50% { opacity: 0; } }
|
|
|
|
/* ===================================================================== */
|
|
/* Right column */
|
|
/* ===================================================================== */
|
|
|
|
/* shared brushed-metal panel treatment for sub-panels inside the rack */
|
|
.knobs, .seq, .faders {
|
|
border: 1px solid var(--hw-edge);
|
|
border-radius: 6px;
|
|
background:
|
|
var(--metal-specular),
|
|
var(--metal-brush),
|
|
/* brighter base than .hardware so they read as plates mounted on top */
|
|
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 sub-panel inside hardware */
|
|
.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);
|
|
}
|
|
.knob-cell .value {
|
|
font-size: 10px; font-variant-numeric: tabular-nums;
|
|
color: var(--hw-fg-hi); letter-spacing: 0.05em;
|
|
background: transparent;
|
|
padding: 0; min-width: 46px; text-align: center;
|
|
}
|
|
.hw-knob { cursor: grab; touch-action: none; }
|
|
.hw-knob.dragging { cursor: grabbing; }
|
|
|
|
/* faders sub-panel */
|
|
.faders {
|
|
padding: 10px 10px 14px;
|
|
display: flex;
|
|
justify-content: space-around;
|
|
align-items: flex-start;
|
|
gap: 8px;
|
|
}
|
|
.fader-cell {
|
|
display: flex; flex-direction: column; align-items: center;
|
|
gap: 4px; flex: 1; min-width: 0;
|
|
}
|
|
.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);
|
|
}
|
|
/* track is a deep slot cut into the panel */
|
|
.fd-track {
|
|
position: relative;
|
|
width: 5px; height: 78px;
|
|
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;
|
|
}
|
|
/* tick marks on either side of the track */
|
|
.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; }
|
|
/* the cap — metallic, sits on the track */
|
|
.fd-cap {
|
|
position: absolute;
|
|
left: 50%; transform: translate(-50%, -50%);
|
|
width: 26px; height: 14px;
|
|
border-radius: 2px;
|
|
background:
|
|
/* horizontal grip line in middle */
|
|
linear-gradient(180deg,
|
|
transparent 38%, rgba(0,0,0,0.55) 46%, rgba(0,0,0,0.55) 54%,
|
|
transparent 62%),
|
|
/* very fine brushed stripes */
|
|
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),
|
|
/* vertical light gradient */
|
|
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);
|
|
cursor: grab; touch-action: none;
|
|
}
|
|
.fd-cap.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 sub-panel — inherits brushed-metal background from .knobs,.seq */
|
|
.seq {
|
|
padding: 8px 10px;
|
|
display: flex; flex-direction: column; gap: 5px;
|
|
}
|
|
.seq-row {
|
|
display: flex; align-items: center; gap: 8px;
|
|
}
|
|
.seq-row .label {
|
|
width: 44px;
|
|
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);
|
|
}
|
|
.leds {
|
|
display: grid; grid-template-columns: repeat(16, 1fr); gap: 2px;
|
|
flex: 1;
|
|
}
|
|
.led {
|
|
height: 12px; 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;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<div class="layout">
|
|
|
|
<!-- ===================== hardware unit ===================== -->
|
|
<div class="hardware">
|
|
<span class="screw tl"></span>
|
|
<span class="screw tr"></span>
|
|
<span class="screw bl"></span>
|
|
<span class="screw br"></span>
|
|
|
|
<!-- left stack: top-bar / screen / bottom-bar -->
|
|
<div class="left-stack">
|
|
|
|
<div class="top-bar">
|
|
<div class="btn-group">
|
|
<button class="hw-btn"><span class="led-red"></span>STOP</button>
|
|
<button class="hw-btn"><span class="ico-play"></span>RUN</button>
|
|
</div>
|
|
<span>48 KHZ</span>
|
|
<span>TAPS: <span class="num">14</span></span>
|
|
<span>CPU: <span class="num">9%</span></span>
|
|
<span class="right">CODE · SINTH</span>
|
|
</div>
|
|
|
|
<section class="screen">
|
|
<div class="glass" id="glass"></div>
|
|
</section>
|
|
|
|
<div class="bottom-bar">
|
|
<span class="running-label"><span class="running-dot"></span>RUNNING<span class="blink">_</span></span>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<!-- right column with knobs + seq -->
|
|
<div class="right-stack">
|
|
|
|
<section class="knobs">
|
|
<div class="knob-cell">
|
|
<span class="label">tempo</span>
|
|
<canvas class="hw-knob" data-min="60" data-max="200" data-value="120" data-decimals="1"></canvas>
|
|
<span class="value">120.0</span>
|
|
</div>
|
|
<div class="knob-cell">
|
|
<span class="label">res</span>
|
|
<canvas class="hw-knob" data-min="0.5" data-max="8" data-value="2.5" data-decimals="2"></canvas>
|
|
<span class="value">2.50</span>
|
|
</div>
|
|
<div class="knob-cell">
|
|
<span class="label">cutoff</span>
|
|
<canvas class="hw-knob" data-min="200" data-max="4000" data-value="800" data-decimals="0"></canvas>
|
|
<span class="value">800</span>
|
|
</div>
|
|
<div class="knob-cell">
|
|
<span class="label">drive</span>
|
|
<canvas class="hw-knob" data-min="0" data-max="1" data-value="0.35" data-decimals="2"></canvas>
|
|
<span class="value">0.35</span>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="faders">
|
|
<div class="fader-cell">
|
|
<span class="label">kick</span>
|
|
<div class="fd-track" data-value="0.78">
|
|
<div class="fd-ticks left"></div>
|
|
<div class="fd-ticks right"></div>
|
|
<div class="fd-cap"></div>
|
|
</div>
|
|
<span class="value">0.78</span>
|
|
</div>
|
|
<div class="fader-cell">
|
|
<span class="label">hat</span>
|
|
<div class="fd-track" data-value="0.55">
|
|
<div class="fd-ticks left"></div>
|
|
<div class="fd-ticks right"></div>
|
|
<div class="fd-cap"></div>
|
|
</div>
|
|
<span class="value">0.55</span>
|
|
</div>
|
|
<div class="fader-cell">
|
|
<span class="label">mel</span>
|
|
<div class="fd-track" data-value="0.62">
|
|
<div class="fd-ticks left"></div>
|
|
<div class="fd-ticks right"></div>
|
|
<div class="fd-cap"></div>
|
|
</div>
|
|
<span class="value">0.62</span>
|
|
</div>
|
|
<div class="fader-cell">
|
|
<span class="label">mix</span>
|
|
<div class="fd-track" data-value="0.85">
|
|
<div class="fd-ticks left"></div>
|
|
<div class="fd-ticks right"></div>
|
|
<div class="fd-cap"></div>
|
|
</div>
|
|
<span class="value">0.85</span>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="seq">
|
|
<div class="seq-row" data-pattern="1000100010001000">
|
|
<span class="label">kicks</span>
|
|
<div class="leds"></div>
|
|
</div>
|
|
<div class="seq-row" data-pattern="0010001000100010">
|
|
<span class="label">hat</span>
|
|
<div class="leds"></div>
|
|
</div>
|
|
<div class="seq-row" data-pattern="1001010001010010">
|
|
<span class="label">melody</span>
|
|
<div class="leds"></div>
|
|
</div>
|
|
</section>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<script>
|
|
// ===========================================================================
|
|
// static syntax-highlighted code (matches reference layout)
|
|
// ===========================================================================
|
|
const PATCH = [
|
|
['# studio hardware emulation — visual exploration', 'com'],
|
|
null,
|
|
[['kw','node'], ['id','kick'], ['op','='], ['fn','step_seq'], ['op','('],
|
|
['id','rate'], ['op','='], ['id','tempo'], ['op',','], ['id','steps'], ['op','='], ['num','16'], ['op',',']],
|
|
[['op',' '], ['id','default'], ['op','='], ['op','['],
|
|
['num','1'], ['op',','], ['num','0'], ['op',','], ['num','0'], ['op',','], ['num','0'], ['op',', '],
|
|
['num','1'], ['op',','], ['num','0'], ['op',','], ['num','0'], ['op',','], ['num','0'], ['op',', '],
|
|
['num','1'], ['op',','], ['num','0'], ['op',','], ['num','0'], ['op',','], ['num','0'], ['op',', '],
|
|
['num','1'], ['op',','], ['num','0'], ['op',','], ['num','0'], ['op',','], ['num','0'], ['op',']'], ['op',')']],
|
|
null,
|
|
[['kw','node'], ['id','bass'], ['op','='], ['fn','osc'], ['op','('],
|
|
['id','saw'], ['op',','], ['id','freq'], ['op','='], ['num','55'], ['op',')']],
|
|
[['kw','node'], ['id','lp'], ['op','='], ['fn','filter'], ['op','('],
|
|
['id','bass'], ['op',','], ['id','cutoff'], ['op','='], ['num','800'], ['op',','],
|
|
['id','q'], ['op','='], ['num','2.5'], ['op',')']],
|
|
null,
|
|
[['kw','out'], ['arrow','← '], ['id','bass'], ['op','*'], ['num','0.6'],
|
|
['op','+'], ['id','kick'], ['op','*'], ['num','0.4']],
|
|
null,
|
|
[['kw','node'], ['id','melody'], ['op','='], ['fn','piano_roll'], ['op','('],
|
|
['id','voice'], ['op','='], ['id','synth'], ['op',',']],
|
|
[['op',' '], ['id','rate'], ['op','='], ['id','tempo'], ['op',','],
|
|
['id','length'], ['op','='], ['num','16'], ['op',',']],
|
|
[['op',' '], ['id','octaves'], ['op','='], ['num','2'], ['op',','],
|
|
['id','gate'], ['op','='], ['num','0.18'], ['op',')']],
|
|
];
|
|
const glass = document.getElementById('glass');
|
|
let lineNum = 0;
|
|
for (const line of PATCH) {
|
|
const div = document.createElement('div');
|
|
div.className = 'code-line';
|
|
if (line === null) {
|
|
lineNum++;
|
|
div.innerHTML = `<span class="ln">${lineNum}</span><span> </span>`;
|
|
} else if (Array.isArray(line) && typeof line[0] === 'string' && line[1] === 'com') {
|
|
lineNum++;
|
|
div.innerHTML = `<span class="ln">${lineNum}</span><span class="syn-com">${line[0]}</span>`;
|
|
} else {
|
|
lineNum++;
|
|
let html = `<span class="ln">${lineNum}</span><span>`;
|
|
for (const [cls, txt] of line) {
|
|
const sep = cls === 'op' ? '' : ' ';
|
|
const klass = cls === 'arrow' ? 'syn-arrow' : `syn-${cls}`;
|
|
html += `<span class="${klass}">${txt}</span>${sep}`;
|
|
}
|
|
html += `</span>`;
|
|
div.innerHTML = html;
|
|
}
|
|
glass.appendChild(div);
|
|
}
|
|
|
|
// ===========================================================================
|
|
// knob — restrained: dark gradient + thin amber arc + single tickmark.
|
|
// No knurled ridges, no brushed metal. Closer to the reference image.
|
|
// ===========================================================================
|
|
// Canvas is bigger than the visible knob so the amber arc + drop shadow have
|
|
// room around the dial. Visible knob radius is ~26px regardless of canvas size.
|
|
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;
|
|
// Fixed pixel sizes so the visible knob doesn't grow with the canvas.
|
|
// Canvas size only adds margin around these for arc + shadow.
|
|
const totalR = 26; // outer edge of the whole knob
|
|
const rimW = 2.5; // dark rim thickness
|
|
const discR = totalR - rimW; // metallic disc fills most of inside
|
|
ctx.clearRect(0, 0, size, size);
|
|
|
|
// (1) drop shadow under the whole knob
|
|
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 OUTSIDE everything (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) thin DARK RIM around the outside (the body of the knob)
|
|
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) METAL — base radial gradient (matte foundation, slightly dark at edge)
|
|
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 look (signature on
|
|
// Moog/Roland synths). Pseudo-random alpha per ring, alternating
|
|
// bright/dark bands, deterministic so the rings don't shimmer.
|
|
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. Strong on top, dark below.
|
|
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 — the bright reflection of the light
|
|
// source running across the upper part of the disc.
|
|
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) RIM LIGHT FROM BELOW — subtle glow on the bottom edge (typical of
|
|
// studio gear photographed under softboxes).
|
|
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 crescent on the upper edge
|
|
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 crescent on the lower edge (shadow side of the bevel)
|
|
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 — small engraved dark notch on the disc, rotates with value.
|
|
// Drawn as a tapered triangle with a subtle bottom highlight to look
|
|
// like it's incised into the metal rather than printed on top.
|
|
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; // fixed half-width of the notch base
|
|
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;
|
|
// dark fill (the engraved groove)
|
|
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();
|
|
// tiny bright lower lip (light catches the inner edge of the groove)
|
|
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();
|
|
}
|
|
|
|
document.querySelectorAll('canvas.hw-knob').forEach((canvas) => {
|
|
const min = parseFloat(canvas.dataset.min);
|
|
const max = parseFloat(canvas.dataset.max);
|
|
const dec = canvas.dataset.decimals !== undefined ? +canvas.dataset.decimals : 1;
|
|
const valueEl = canvas.parentNode.querySelector('.value');
|
|
let value = parseFloat(canvas.dataset.value);
|
|
const ctx = setupCanvas(canvas, KNOB_SIZE);
|
|
const fmt = (v) => v.toFixed(dec);
|
|
const redraw = () => {
|
|
const norm = Math.max(0, Math.min(1, (value - min) / (max - min)));
|
|
drawKnob(ctx, KNOB_SIZE, norm);
|
|
if (valueEl) valueEl.textContent = fmt(value);
|
|
};
|
|
redraw();
|
|
|
|
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 = value;
|
|
const span = max - min;
|
|
const onMove = (ev) => {
|
|
const dy = startY - ev.clientY;
|
|
const factor = ev.shiftKey ? 4 : 1;
|
|
const delta = (dy / 200) * span / factor;
|
|
value = Math.max(min, Math.min(max, startV + delta));
|
|
redraw();
|
|
};
|
|
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', () => {
|
|
value = parseFloat(canvas.dataset.value);
|
|
redraw();
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// faders — drag the cap or click anywhere on the track to jump
|
|
// ===========================================================================
|
|
document.querySelectorAll('.fader-cell').forEach((cell) => {
|
|
const track = cell.querySelector('.fd-track');
|
|
const cap = cell.querySelector('.fd-cap');
|
|
const valueEl = cell.querySelector('.value');
|
|
let value = parseFloat(track.dataset.value || '0.5');
|
|
|
|
function paint() {
|
|
const trackH = track.clientHeight;
|
|
cap.style.top = ((1 - value) * trackH) + 'px';
|
|
if (valueEl) valueEl.textContent = value.toFixed(2);
|
|
}
|
|
// wait for layout
|
|
requestAnimationFrame(paint);
|
|
|
|
function valueAt(clientY) {
|
|
const rect = track.getBoundingClientRect();
|
|
const norm = 1 - (clientY - rect.top) / rect.height;
|
|
return Math.max(0, Math.min(1, norm));
|
|
}
|
|
|
|
function startDrag(e, jumpToPointer) {
|
|
e.preventDefault();
|
|
cap.classList.add('dragging');
|
|
track.setPointerCapture(e.pointerId);
|
|
document.body.style.cursor = 'grabbing';
|
|
if (jumpToPointer) { value = valueAt(e.clientY); paint(); }
|
|
const onMove = (ev) => { value = valueAt(ev.clientY); paint(); };
|
|
const onUp = () => {
|
|
cap.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);
|
|
}
|
|
// click on track jumps to that position; drag continues from there
|
|
track.addEventListener('pointerdown', (e) => startDrag(e, true));
|
|
});
|
|
|
|
// ===========================================================================
|
|
// step LEDs
|
|
// ===========================================================================
|
|
document.querySelectorAll('.seq-row').forEach((row) => {
|
|
const pattern = row.dataset.pattern.split('').map((c) => c === '1' ? 1 : 0);
|
|
const container = row.querySelector('.leds');
|
|
const cells = [];
|
|
for (let i = 0; i < 16; i++) {
|
|
const led = document.createElement('div');
|
|
led.className = 'led' + (pattern[i] ? ' on' : '') + (i % 4 === 0 ? ' beat' : '');
|
|
led.addEventListener('click', () => {
|
|
pattern[i] = pattern[i] ? 0 : 1;
|
|
led.classList.toggle('on', !!pattern[i]);
|
|
});
|
|
container.appendChild(led);
|
|
cells.push(led);
|
|
}
|
|
row._cells = cells;
|
|
});
|
|
|
|
let phStep = 0;
|
|
const allRows = document.querySelectorAll('.seq-row');
|
|
setInterval(() => {
|
|
allRows.forEach((row) => {
|
|
row._cells.forEach((c, i) => c.classList.toggle('playhead', i === phStep));
|
|
});
|
|
phStep = (phStep + 1) % 16;
|
|
}, 125);
|
|
</script>
|
|
</body>
|
|
</html>
|