0

ADSR Envelope Editor

drag the four ADSR knee points · click to retrigger

A live editor for the classic synthesizer ADSR amplitude envelope — attack, decay, sustain, release — drawn as a four-segment piecewise-linear curve that ramps from silence up to a peak, falls to the sustain level, holds while the note is gated, then decays back to zero on release. A 220 Hz carrier sine is multiplied by the envelope and scrolls beneath it, so you can see the audible amplitude shape as if a note were being held and released on a 3-second loop. Drag the four knee points to retune , , the sustain level , and in real time; the HUD shows each stage in milliseconds and the current envelope value. Click anywhere off the knees to retrigger the note early.

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.