0
ADSR Envelope Editor
drag the four ADSR knee points · click to retrigger
idle
315 lines · vanilla
view source
// ADSR synthesizer envelope editor with live carrier waveform.
// Drag the four knee points to set attack / decay / sustain / release.
// Click anywhere off a knee to retrigger the note.
const HIT = 16;
let W = 0, H = 0;
// Envelope params (seconds, level 0..1)
let A = 0.25, D = 0.35, S = 0.55, R = 0.65;
let PEAK = 1.0;
// Note cycle (3 s loop)
let cycle = 3.0;
let phase = 0; // seconds within current cycle
let noteOn = true; // gate flag for current cycle
let gateLen = 1.6; // how long the note is held within the cycle
let releaseStartLevel = 0;
let drag = -1; // 0=attackPeak, 1=decayKnee, 2=sustainEnd, 3=releaseEnd; -1 none
let hover = -1;
let prevDown = false;
// Envelope display rect
let envBox = { x: 0, y: 0, w: 0, h: 0 };
// Waveform history (post-amplitude, for animated rendering)
let wave, waveIdx = 0;
// Palette — zinc background, cyan accent, monochrome with one warm playhead.
const BG = "#18181b"; // zinc-900
const PANEL = "#1f1f23"; // slightly lifted panel
const PANEL_EDGE = "rgba(255,255,255,0.04)";
const GRID = "rgba(228,228,231,0.05)"; // zinc-200 @ 5%
const GRID_STRONG = "rgba(228,228,231,0.09)";
const MUTED = "rgba(228,228,231,0.45)"; // zinc-200 muted
const TEXT = "#e4e4e7"; // zinc-200
const ACCENT = "#22d3ee"; // cyan-400
const ACCENT_DIM = "rgba(34,211,238,0.55)";
const ACCENT_FILL = "rgba(34,211,238,0.16)";
const CARRIER_LINE = "rgba(34,211,238,0.55)";
const CARRIER_FILL = "rgba(34,211,238,0.10)";
const PLAYHEAD = "rgba(251,191,36,0.85)"; // amber-400
function init({ width, height }) {
W = width; H = height;
wave = new Float32Array(512);
}
function layout() {
const pad = 14;
const hudH = 56;
const top = pad + hudH + 8;
// Envelope occupies upper ~55%
const envH = Math.max(120, ((H - top - pad - 8) * 0.58) | 0);
envBox = { x: pad, y: top, w: W - pad * 2, h: envH };
}
// Total envelope timeline in seconds (so knee points have an x position).
function totalT() { return A + D + Math.max(0.4, gateLen - A - D) + R; }
function envValueAt(t) {
if (t < 0) return 0;
if (t < A) return PEAK * (t / Math.max(0.0001, A));
if (t < A + D) {
const u = (t - A) / Math.max(0.0001, D);
return PEAK + (S - PEAK) * u;
}
if (t < gateLen) return S;
const rt = t - gateLen;
if (rt >= R) return 0;
return releaseStartLevel * (1 - rt / Math.max(0.0001, R));
}
function kneePoints() {
const T = totalT();
const b = envBox;
const sx = (s) => b.x + (s / T) * b.w;
const sy = (lvl) => b.y + b.h - lvl * b.h;
return [
{ x: sx(A), y: sy(PEAK), label: "A" },
{ x: sx(A + D), y: sy(S), label: "D" },
{ x: sx(gateLen), y: sy(S), label: "S" },
{ x: sx(gateLen + R), y: sy(0), label: "R" },
];
}
function clamp(v, lo, hi) { return v < lo ? lo : v > hi ? hi : v; }
function nearestKnee(px, py) {
const pts = kneePoints();
let best = -1, bd = HIT * HIT;
for (let i = 0; i < pts.length; i++) {
const dx = pts[i].x - px, dy = pts[i].y - py;
const d = dx * dx + dy * dy;
if (d < bd) { bd = d; best = i; }
}
return best;
}
function handleDrag(input) {
const down = input.mouseDown;
const px = input.mouseX, py = input.mouseY;
hover = nearestKnee(px, py);
if (down && !prevDown) drag = nearestKnee(px, py);
if (!down) drag = -1;
prevDown = down;
if (drag >= 0 && down) {
const b = envBox;
const T = totalT();
const tAt = clamp((px - b.x) / b.w, 0, 1) * T;
const lvAt = clamp(1 - (py - b.y) / b.h, 0, 1);
if (drag === 0) {
A = clamp(tAt, 0.02, 1.6);
} else if (drag === 1) {
D = clamp(tAt - A, 0.02, 1.6);
S = clamp(lvAt, 0.0, 1.0);
} else if (drag === 2) {
gateLen = clamp(tAt, A + D + 0.05, cycle - 0.2);
S = clamp(lvAt, 0.0, 1.0);
} else if (drag === 3) {
R = clamp(tAt - gateLen, 0.02, 1.8);
}
}
}
function roundRect(ctx, x, y, w, h, r) {
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.arcTo(x + w, y, x + w, y + rr, rr);
ctx.lineTo(x + w, y + h - rr);
ctx.arcTo(x + w, y + h, x + w - rr, y + h, rr);
ctx.lineTo(x + rr, y + h);
ctx.arcTo(x, y + h, x, y + h - rr, rr);
ctx.lineTo(x, y + rr);
ctx.arcTo(x, y, x + rr, y, rr);
ctx.closePath();
}
function drawPanel(ctx, x, y, w, h) {
ctx.fillStyle = PANEL;
roundRect(ctx, x, y, w, h, 8);
ctx.fill();
ctx.strokeStyle = PANEL_EDGE;
ctx.lineWidth = 1;
ctx.stroke();
}
function drawEnvelope(ctx) {
const b = envBox;
drawPanel(ctx, b.x, b.y, b.w, b.h);
const T = totalT();
const sx = (s) => b.x + (s / T) * b.w;
const sy = (lv) => b.y + b.h - lv * b.h;
// --- Subtle time grid (every 100 ms) ---
ctx.save();
ctx.beginPath();
roundRect(ctx, b.x, b.y, b.w, b.h, 8);
ctx.clip();
ctx.lineWidth = 1;
for (let s = 0.1; s < T; s += 0.1) {
const x = sx(s);
const isHalf = Math.abs((s * 10) % 5) < 0.01;
ctx.strokeStyle = isHalf ? GRID_STRONG : GRID;
ctx.beginPath(); ctx.moveTo(x, b.y + 1); ctx.lineTo(x, b.y + b.h - 1); ctx.stroke();
}
// horizontal level guides at 0.25, 0.5, 0.75
for (let i = 1; i < 4; i++) {
const y = b.y + (b.h * i) / 4;
ctx.strokeStyle = i === 2 ? GRID_STRONG : GRID;
ctx.beginPath(); ctx.moveTo(b.x + 1, y); ctx.lineTo(b.x + b.w - 1, y); ctx.stroke();
}
// --- Filled envelope area ---
ctx.fillStyle = ACCENT_FILL;
ctx.beginPath();
ctx.moveTo(sx(0), sy(0));
ctx.lineTo(sx(A), sy(PEAK));
ctx.lineTo(sx(A + D), sy(S));
ctx.lineTo(sx(gateLen), sy(S));
ctx.lineTo(sx(gateLen + R), sy(0));
ctx.lineTo(sx(gateLen + R), sy(0));
ctx.closePath();
ctx.fill();
// --- Envelope polyline ---
ctx.strokeStyle = ACCENT;
ctx.lineWidth = 2;
ctx.lineJoin = "round";
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(sx(0), sy(0));
ctx.lineTo(sx(A), sy(PEAK));
ctx.lineTo(sx(A + D), sy(S));
ctx.lineTo(sx(gateLen), sy(S));
ctx.lineTo(sx(gateLen + R), sy(0));
ctx.stroke();
// --- Stage labels (A D S R) above each segment midpoint ---
ctx.fillStyle = MUTED;
ctx.font = "11px ui-monospace, Menlo, monospace";
ctx.textBaseline = "alphabetic";
ctx.textAlign = "center";
const labelY = b.y + 14;
const segs = [
{ tag: "A", x0: 0, x1: A },
{ tag: "D", x0: A, x1: A + D },
{ tag: "S", x0: A + D, x1: gateLen },
{ tag: "R", x0: gateLen, x1: gateLen + R },
];
for (const s of segs) {
const cx = sx((s.x0 + s.x1) / 2);
if (cx > b.x + 8 && cx < b.x + b.w - 8) ctx.fillText(s.tag, cx, labelY);
}
// --- Playhead ---
const t = (phase / cycle) * T;
const phX = sx(Math.min(t, T));
if (phX >= b.x && phX <= b.x + b.w) {
ctx.strokeStyle = PLAYHEAD;
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(phX, b.y + 2); ctx.lineTo(phX, b.y + b.h - 2); ctx.stroke();
const lvNow = envValueAt(phase);
ctx.fillStyle = PLAYHEAD;
ctx.beginPath(); ctx.arc(phX, sy(lvNow), 3, 0, Math.PI * 2); ctx.fill();
}
ctx.restore();
// --- Knee points with hover glow ring ---
const pts = kneePoints();
for (let i = 0; i < pts.length; i++) {
const active = drag === i || hover === i;
const r = 6;
// hover/active glow ring
if (active) {
ctx.strokeStyle = "rgba(34,211,238,0.35)";
ctx.lineWidth = 1;
ctx.beginPath(); ctx.arc(pts[i].x, pts[i].y, r + 6, 0, Math.PI * 2); ctx.stroke();
ctx.strokeStyle = "rgba(34,211,238,0.75)";
ctx.beginPath(); ctx.arc(pts[i].x, pts[i].y, r + 3, 0, Math.PI * 2); ctx.stroke();
}
// filled dot
ctx.fillStyle = active ? ACCENT : "#0c0c0e";
ctx.beginPath(); ctx.arc(pts[i].x, pts[i].y, r, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = ACCENT;
ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(pts[i].x, pts[i].y, r, 0, Math.PI * 2); ctx.stroke();
}
}
function drawWave(ctx) {
const pad = 14;
const y0 = envBox.y + envBox.h + 10;
const wh = Math.max(60, H - y0 - pad - 22);
const wx = pad, ww = W - pad * 2;
drawPanel(ctx, wx, y0, ww, wh);
// center line
ctx.save();
ctx.beginPath();
roundRect(ctx, wx, y0, ww, wh, 8);
ctx.clip();
ctx.strokeStyle = GRID;
ctx.beginPath(); ctx.moveTo(wx, y0 + wh / 2); ctx.lineTo(wx + ww, y0 + wh / 2); ctx.stroke();
// Build polyline points for fill + stroke
const half = wh / 2 - 3;
const cy = y0 + wh / 2;
ctx.beginPath();
ctx.moveTo(wx, cy);
for (let i = 0; i < wave.length; i++) {
const idx = (waveIdx + i) % wave.length;
const px = wx + (i / (wave.length - 1)) * ww;
const py = cy - wave[idx] * half;
ctx.lineTo(px, py);
}
ctx.lineTo(wx + ww, cy);
ctx.closePath();
ctx.fillStyle = CARRIER_FILL;
ctx.fill();
// stroke top edge
ctx.strokeStyle = CARRIER_LINE;
ctx.lineWidth = 1.25;
ctx.beginPath();
for (let i = 0; i < wave.length; i++) {
const idx = (waveIdx + i) % wave.length;
const px = wx + (i / (wave.length - 1)) * ww;
const py = cy - wave[idx] * half;
if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
}
ctx.stroke();
// playhead bar at the right edge of the scrolling wave (newest sample)
ctx.strokeStyle = PLAYHEAD;
ctx.lineWidth = 1;
const phX = wx + ww - 0.5;
ctx.beginPath(); ctx.moveTo(phX, y0 + 2); ctx.lineTo(phX, y0 + wh - 2); ctx.stroke();
ctx.restore();
// tiny caption
ctx.fillStyle = MUTED;
ctx.font = "10px ui-monospace, Menlo, monospace";
ctx.textAlign = "left";
ctx.textBaseline = "top";
ctx.fillText("carrier 220 Hz × env", wx + 6, y0 + wh + 4);
}
function drawHUD(ctx) {
const pad = 14;
const x = pad, y = pad, w = W - pad * 2, h = 44;
drawPanel(ctx, x, y, w, h);
ctx.fillStyle = TEXT;
ctx.font = "12px ui-monospace, Menlo, monospace";
ctx.textBaseline = "middle";
ctx.textAlign = "left";
const stats = `A ${(A * 1000) | 0}ms D ${(D * 1000) | 0}ms S ${S.toFixed(2)} R ${(R * 1000) | 0}ms`;
ctx.fillText(stats, x + 12, y + h / 2);
// right-aligned gate / cycle state
ctx.textAlign = "right";
ctx.fillStyle = MUTED;
const gateOn = phase < gateLen;
const dot = gateOn ? ACCENT : "rgba(228,228,231,0.25)";
ctx.fillStyle = dot;
ctx.beginPath(); ctx.arc(x + w - 14, y + h / 2, 4, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = MUTED;
ctx.fillText(`${gateOn ? "gate on" : "gate off"} ${phase.toFixed(2)} / ${cycle.toFixed(1)}s`, x + w - 24, y + h / 2);
}
function drawFooterHint(ctx) {
// Bottom-left mono caption — already covered by HUD; show drag hint quietly at bottom.
ctx.fillStyle = MUTED;
ctx.font = "10px ui-monospace, Menlo, monospace";
ctx.textBaseline = "bottom";
ctx.textAlign = "left";
ctx.fillText("drag knees · click off-knee to retrigger", 16, H - 6);
}
function tick({ ctx, dt, width, height, input }) {
if (width !== W || height !== H) { W = width; H = height; }
layout();
// background
ctx.fillStyle = BG;
ctx.fillRect(0, 0, W, H);
// Click anywhere outside knees triggers an early note-on.
for (const c of input.consumeClicks()) {
let onKnee = false;
const pts = kneePoints();
for (const p of pts) {
const dx = p.x - c.x, dy = p.y - c.y;
if (dx * dx + dy * dy < HIT * HIT) { onKnee = true; break; }
}
if (!onKnee) { phase = 0; noteOn = true; releaseStartLevel = 0; }
}
handleDrag(input);
// Advance cycle
const wasOn = phase < gateLen;
phase += dt;
if (phase >= cycle) { phase = 0; noteOn = true; releaseStartLevel = 0; }
if (wasOn && phase >= gateLen) releaseStartLevel = envValueAt(gateLen - 0.0001);
// Build a sliding waveform: carrier modulated by envelope.
const samplesPerSec = 4000;
const n = Math.min(wave.length, Math.max(1, (dt * samplesPerSec) | 0));
const freq = 220;
for (let i = 0; i < n; i++) {
const localT = phase - (n - i) / samplesPerSec;
const e = envValueAt(localT);
const carrier = Math.sin(2 * Math.PI * freq * localT);
wave[waveIdx] = e * carrier;
waveIdx = (waveIdx + 1) % wave.length;
}
drawHUD(ctx);
drawEnvelope(ctx);
drawWave(ctx);
drawFooterHint(ctx);
}
Comments (0)
Log in to comment.