🎹 Web Audio API
Sigil Synth
A playable instrument in 150 lines: pentatonic pads, theremin filter sweeps, and a live waveform scope drawn from the AnalyserNode.
Use it for: Music tools · interactive brand experiences · games
tap a pad (or press A–K) to wake the audio engine
The complete source
This is the exact file rendering the demo above — a single self-contained React client component with its styles inline. Copy or download it, drop it into any React / Next.js project, and it runs. No extra dependencies.
synth.jsx
1"use client";2import { useEffect, useRef, useState } from "react";34/* ============================================================5SIGIL SYNTH — a playable instrument in ~150 lines.6Web Audio API: oscillators → filter → delay → analyser.7Keys play a pentatonic scale (so everything sounds good);8moving the pointer bends the filter like a theremin.9The waveform visual is drawn from the live AnalyserNode.10============================================================ */1112const SCALE = [220, 261.63, 293.66, 329.63, 392, 440, 523.25, 587.33]; // A minor pentatonic-ish13const KEYS = "asdfghjk";1415export default function Synth() {16const canvasRef = useRef(null);17const audioRef = useRef(null);18const [ready, setReady] = useState(false);19const [lastNote, setLastNote] = useState("—");2021// build the audio graph once, on first user gesture (browser policy)22const ensureAudio = () => {23if (audioRef.current) return audioRef.current;24const ctx = new (window.AudioContext || window.webkitAudioContext)();25const filter = ctx.createBiquadFilter();26filter.type = "lowpass";27filter.frequency.value = 1200;28const delay = ctx.createDelay();29delay.delayTime.value = 0.28;30const feedback = ctx.createGain();31feedback.gain.value = 0.35;32const master = ctx.createGain();33master.gain.value = 0.9;34const analyser = ctx.createAnalyser();35analyser.fftSize = 1024;3637filter.connect(master);38filter.connect(delay);39delay.connect(feedback);40feedback.connect(delay);41delay.connect(master);42master.connect(analyser);43analyser.connect(ctx.destination);4445audioRef.current = { ctx, filter, master, analyser };46setReady(true);47return audioRef.current;48};4950const play = (freq, label) => {51const { ctx, filter } = ensureAudio();52if (ctx.state === "suspended") ctx.resume();53const osc = ctx.createOscillator();54const osc2 = ctx.createOscillator();55const env = ctx.createGain();56osc.type = "sawtooth";57osc2.type = "triangle";58osc.frequency.value = freq;59osc2.frequency.value = freq * 2.003; // slight detune = shimmer60env.gain.setValueAtTime(0, ctx.currentTime);61env.gain.linearRampToValueAtTime(0.25, ctx.currentTime + 0.02);62env.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 1.4);63osc.connect(env);64osc2.connect(env);65env.connect(filter);66osc.start();67osc2.start();68osc.stop(ctx.currentTime + 1.5);69osc2.stop(ctx.currentTime + 1.5);70setLastNote(label);71};7273// pointer = theremin filter sweep74const onPointer = (e) => {75if (!audioRef.current) return;76const b = e.currentTarget.getBoundingClientRect();77const x = (e.clientX - b.left) / b.width;78audioRef.current.filter.frequency.value = 200 + x * x * 6000;79};8081// keyboard82useEffect(() => {83const down = (e) => {84const i = KEYS.indexOf(e.key.toLowerCase());85if (i >= 0 && !e.repeat) play(SCALE[i], KEYS[i].toUpperCase());86};87window.addEventListener("keydown", down);88return () => window.removeEventListener("keydown", down);89}, []);9091// waveform scope92useEffect(() => {93const canvas = canvasRef.current;94const c = canvas.getContext("2d");95let raf = 0;96const data = new Uint8Array(1024);97const draw = () => {98raf = requestAnimationFrame(draw);99const dpr = Math.min(window.devicePixelRatio || 1, 2);100if (canvas.width !== canvas.clientWidth * dpr) {101canvas.width = canvas.clientWidth * dpr;102canvas.height = canvas.clientHeight * dpr;103}104c.setTransform(dpr, 0, 0, dpr, 0, 0);105const w = canvas.clientWidth;106const h = canvas.clientHeight;107c.clearRect(0, 0, w, h);108const a = audioRef.current?.analyser;109c.lineWidth = 2;110const grad = c.createLinearGradient(0, 0, w, 0);111grad.addColorStop(0, "#7c3aed");112grad.addColorStop(0.5, "#06b6d4");113grad.addColorStop(1, "#ffa500");114c.strokeStyle = grad;115c.beginPath();116if (a) {117a.getByteTimeDomainData(data);118for (let i = 0; i < data.length; i++) {119const x = (i / data.length) * w;120const y = (data[i] / 255) * h;121i === 0 ? c.moveTo(x, y) : c.lineTo(x, y);122}123} else {124c.moveTo(0, h / 2);125c.lineTo(w, h / 2);126}127c.stroke();128};129draw();130return () => cancelAnimationFrame(raf);131}, []);132133return (134<div135onPointerMove={onPointer}136className="rounded-3xl border border-white/15 p-6 md:p-10"137style={{ background: "#0a0a12" }}138>139<canvas ref={canvasRef} className="w-full h-[160px] block" />140<div className="mt-6 grid grid-cols-4 md:grid-cols-8 gap-3">141{SCALE.map((f, i) => (142<button143key={f}144onClick={() => play(f, KEYS[i].toUpperCase())}145className="rounded-2xl py-6 text-[1.6rem] font-black text-white border border-white/15 hover:scale-105 active:scale-95 transition-transform"146style={{147background: `linear-gradient(160deg, hsl(${260 - i * 22} 80% 55% / .8), hsl(${260 - i * 22} 80% 30% / .6))`,148}}149>150{KEYS[i].toUpperCase()}151</button>152))}153</div>154<p className="mt-5 text-center text-[1.3rem] text-white/55">155{ready156? `playing: ${lastNote} · keys A–K play notes · move pointer left↔right to sweep the filter`157: "tap a pad (or press A–K) to wake the audio engine"}158</p>159</div>160);161}162