.wrap{position:relative;width:92%;height:84%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px}
canvas{width:100%;height:70%;display:block}
.cap{font:600 11px/1 monospace;color:var(--muted);letter-spacing:.02em}
.cap .full{display:none}
.play{position:absolute;right:0;bottom:0;width:clamp(28px,15%,40px);aspect-ratio:1;border-radius:50%;border:1px solid var(--line);background:var(--surface);display:grid;place-items:center;cursor:pointer;box-shadow:0 2px 8px rgba(0,0,0,.12)}
.play .tri{width:0;height:0;border-style:solid;border-width:6px 0 6px 9px;border-color:transparent transparent transparent var(--fg);margin-left:2px}
.play:active{transform:scale(.92)}
@media (min-width:460px){
.cap{font-size:16px}
.cap .short{display:none}
.cap .full{display:inline}
.play{width:44px;height:44px}
.play .tri{border-width:8px 0 8px 12px;margin-left:3px}
}
const cv = document.getElementById('cv');
const g2d = cv.getContext('2d');
const cs = getComputedStyle(document.documentElement);
const LINEC = cs.getPropertyValue('--muted').trim() || '#888';
const ACCENT = cs.getPropertyValue('--accent').trim() || '#5b5bf7';
let w, h;
function resize() {
const dpr = Math.min(devicePixelRatio || 1, 2);
w = cv.clientWidth; h = cv.clientHeight;
cv.width = w * dpr; cv.height = h * dpr;
g2d.setTransform(dpr, 0, 0, dpr, 0, 0);
}
addEventListener('resize', resize);
resize();
const A = 0.14, D = 0.30, S = 0.68, SUSTAIN = 0.45;
function envY(x) {
if (x <= A) return x / A;
if (x <= D) return 1 - (1 - SUSTAIN) * (x - A) / (D - A);
if (x <= S) return SUSTAIN;
return SUSTAIN * (1 - (x - S) / (1 - S));
}
function drawCurve(pad) {
g2d.clearRect(0, 0, w, h);
g2d.beginPath();
g2d.moveTo(pad, h - pad);
for (let px = 0; px <= w - pad * 2; px++) {
const x = px / (w - pad * 2);
g2d.lineTo(pad + px, h - pad - envY(x) * (h - pad * 2));
}
g2d.lineTo(w - pad, h - pad);
g2d.closePath();
g2d.fillStyle = ACCENT + '22';
g2d.fill();
g2d.beginPath();
g2d.moveTo(pad, h - pad);
for (let px = 0; px <= w - pad * 2; px++) {
const x = px / (w - pad * 2);
g2d.lineTo(pad + px, h - pad - envY(x) * (h - pad * 2));
}
g2d.strokeStyle = ACCENT;
g2d.lineWidth = 2;
g2d.stroke();
}
const LOOP_MS = 1800;
function frame(t) {
const pad = 10;
drawCurve(pad);
const frac = (t % LOOP_MS) / LOOP_MS;
const px = pad + frac * (w - pad * 2);
const y = envY(frac);
g2d.strokeStyle = LINEC;
g2d.lineWidth = 1;
g2d.beginPath();
g2d.moveTo(px, h - pad);
g2d.lineTo(px, h - pad - y * (h - pad * 2));
g2d.stroke();
g2d.beginPath();
g2d.arc(px, h - pad - y * (h - pad * 2), 3.5, 0, Math.PI * 2);
g2d.fillStyle = ACCENT;
g2d.fill();
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
document.getElementById('play').addEventListener('click', () => {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const now = ctx.currentTime;
const total = 1.1;
const aT = total * A, dT = total * D, sT = total * S, rT = total;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sawtooth';
osc.frequency.value = 220;
gain.gain.setValueAtTime(0, now);
gain.gain.linearRampToValueAtTime(0.14, now + aT);
gain.gain.linearRampToValueAtTime(0.14 * SUSTAIN, now + dT);
gain.gain.setValueAtTime(0.14 * SUSTAIN, now + sT);
gain.gain.linearRampToValueAtTime(0.0001, now + rT);
osc.connect(gain).connect(ctx.destination);
osc.start(now);
osc.stop(now + rT + 0.05);
osc.onended = () => ctx.close();
});