<div class="wrap">
<div class="grid">
<div class="cell" id="c-sine"><canvas data-type="sine"></canvas><span>sine</span></div>
<div class="cell" id="c-square"><canvas data-type="square"></canvas><span>square</span></div>
<div class="cell" id="c-saw"><canvas data-type="sawtooth"></canvas><span>saw</span></div>
<div class="cell" id="c-triangle"><canvas data-type="triangle"></canvas><span>triangle</span></div>
</div>
<button class="play" id="play" type="button" aria-label="play sound"><span class="tri"></span></button>
</div>
.wrap{position:relative;width:94%;height:88%}
.grid{display:grid;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;gap:6%;width:100%;height:100%}
.cell{position:relative;border:1px solid var(--line);border-radius:10px;background:var(--surface);overflow:hidden;transition:box-shadow .2s,border-color .2s}
.cell.go{border-color:var(--accent);box-shadow:0 0 0 2px color-mix(in srgb,var(--accent) 30%,transparent)}
.cell canvas{position:absolute;inset:0;width:100%;height:100%}
.cell span{position:absolute;left:6px;bottom:4px;font:600 9px/1 monospace;color:var(--muted);z-index:2}
.play{position:absolute;right:0;bottom:0;width:clamp(26px,14%,38px);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);z-index:3}
.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)}
const TYPES = ['sine', 'square', 'sawtooth', 'triangle'];
const canvases = {};
TYPES.forEach((t) => { canvases[t] = document.querySelector('canvas[data-type="' + t + '"]'); });
const cs = getComputedStyle(document.documentElement);
const ACCENT = cs.getPropertyValue('--accent').trim() || '#5b5bf7';
function wave(type, phase) {
const p = phase - Math.floor(phase);
if (type === 'sine') return Math.sin(p * Math.PI * 2);
if (type === 'square') return p < 0.5 ? 1 : -1;
if (type === 'sawtooth') return p * 2 - 1;
return p < 0.5 ? p * 4 - 1 : 3 - p * 4;
}
function draw(type, t) {
const cv = canvases[type];
const g2d = cv.getContext('2d');
const dpr = Math.min(devicePixelRatio || 1, 2);
const w = cv.clientWidth, h = cv.clientHeight;
if (cv.width !== w * dpr || cv.height !== h * dpr) { cv.width = w * dpr; cv.height = h * dpr; }
g2d.setTransform(dpr, 0, 0, dpr, 0, 0);
g2d.clearRect(0, 0, w, h);
g2d.beginPath();
const cycles = 2.4, scroll = t * 0.00035;
for (let x = 0; x <= w; x++) {
const phase = (x / w) * cycles + scroll;
const y = h / 2 - wave(type, phase) * (h * 0.34);
if (x === 0) g2d.moveTo(x, y); else g2d.lineTo(x, y);
}
g2d.strokeStyle = ACCENT;
g2d.lineWidth = 1.8;
g2d.stroke();
}
function loop(t) {
TYPES.forEach((type) => draw(type, t));
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
document.getElementById('play').addEventListener('click', () => {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const now = ctx.currentTime;
TYPES.forEach((type, i) => {
const t = now + i * 0.24;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = type;
osc.frequency.value = 261.63;
gain.gain.setValueAtTime(0, t);
gain.gain.linearRampToValueAtTime(0.1, t + 0.02);
gain.gain.exponentialRampToValueAtTime(0.0001, t + 0.22);
osc.connect(gain).connect(ctx.destination);
osc.start(t);
osc.stop(t + 0.23);
const cellId = type === 'sawtooth' ? 'c-saw' : ('c-' + type);
setTimeout(() => {
const el = document.getElementById(cellId);
el.classList.add('go');
setTimeout(() => el.classList.remove('go'), 200);
}, i * 240);
});
setTimeout(() => ctx.close(), 1100);
});