🎹 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";
3
4/* ============================================================
5 SIGIL SYNTH — a playable instrument in ~150 lines.
6 Web Audio API: oscillators → filter → delay → analyser.
7 Keys play a pentatonic scale (so everything sounds good);
8 moving the pointer bends the filter like a theremin.
9 The waveform visual is drawn from the live AnalyserNode.
10 ============================================================ */
11
12const SCALE = [220, 261.63, 293.66, 329.63, 392, 440, 523.25, 587.33]; // A minor pentatonic-ish
13const KEYS = "asdfghjk";
14
15export default function Synth() {
16 const canvasRef = useRef(null);
17 const audioRef = useRef(null);
18 const [ready, setReady] = useState(false);
19 const [lastNote, setLastNote] = useState("—");
20
21 // build the audio graph once, on first user gesture (browser policy)
22 const ensureAudio = () => {
23 if (audioRef.current) return audioRef.current;
24 const ctx = new (window.AudioContext || window.webkitAudioContext)();
25 const filter = ctx.createBiquadFilter();
26 filter.type = "lowpass";
27 filter.frequency.value = 1200;
28 const delay = ctx.createDelay();
29 delay.delayTime.value = 0.28;
30 const feedback = ctx.createGain();
31 feedback.gain.value = 0.35;
32 const master = ctx.createGain();
33 master.gain.value = 0.9;
34 const analyser = ctx.createAnalyser();
35 analyser.fftSize = 1024;
36
37 filter.connect(master);
38 filter.connect(delay);
39 delay.connect(feedback);
40 feedback.connect(delay);
41 delay.connect(master);
42 master.connect(analyser);
43 analyser.connect(ctx.destination);
44
45 audioRef.current = { ctx, filter, master, analyser };
46 setReady(true);
47 return audioRef.current;
48 };
49
50 const play = (freq, label) => {
51 const { ctx, filter } = ensureAudio();
52 if (ctx.state === "suspended") ctx.resume();
53 const osc = ctx.createOscillator();
54 const osc2 = ctx.createOscillator();
55 const env = ctx.createGain();
56 osc.type = "sawtooth";
57 osc2.type = "triangle";
58 osc.frequency.value = freq;
59 osc2.frequency.value = freq * 2.003; // slight detune = shimmer
60 env.gain.setValueAtTime(0, ctx.currentTime);
61 env.gain.linearRampToValueAtTime(0.25, ctx.currentTime + 0.02);
62 env.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 1.4);
63 osc.connect(env);
64 osc2.connect(env);
65 env.connect(filter);
66 osc.start();
67 osc2.start();
68 osc.stop(ctx.currentTime + 1.5);
69 osc2.stop(ctx.currentTime + 1.5);
70 setLastNote(label);
71 };
72
73 // pointer = theremin filter sweep
74 const onPointer = (e) => {
75 if (!audioRef.current) return;
76 const b = e.currentTarget.getBoundingClientRect();
77 const x = (e.clientX - b.left) / b.width;
78 audioRef.current.filter.frequency.value = 200 + x * x * 6000;
79 };
80
81 // keyboard
82 useEffect(() => {
83 const down = (e) => {
84 const i = KEYS.indexOf(e.key.toLowerCase());
85 if (i >= 0 && !e.repeat) play(SCALE[i], KEYS[i].toUpperCase());
86 };
87 window.addEventListener("keydown", down);
88 return () => window.removeEventListener("keydown", down);
89 }, []);
90
91 // waveform scope
92 useEffect(() => {
93 const canvas = canvasRef.current;
94 const c = canvas.getContext("2d");
95 let raf = 0;
96 const data = new Uint8Array(1024);
97 const draw = () => {
98 raf = requestAnimationFrame(draw);
99 const dpr = Math.min(window.devicePixelRatio || 1, 2);
100 if (canvas.width !== canvas.clientWidth * dpr) {
101 canvas.width = canvas.clientWidth * dpr;
102 canvas.height = canvas.clientHeight * dpr;
103 }
104 c.setTransform(dpr, 0, 0, dpr, 0, 0);
105 const w = canvas.clientWidth;
106 const h = canvas.clientHeight;
107 c.clearRect(0, 0, w, h);
108 const a = audioRef.current?.analyser;
109 c.lineWidth = 2;
110 const grad = c.createLinearGradient(0, 0, w, 0);
111 grad.addColorStop(0, "#7c3aed");
112 grad.addColorStop(0.5, "#06b6d4");
113 grad.addColorStop(1, "#ffa500");
114 c.strokeStyle = grad;
115 c.beginPath();
116 if (a) {
117 a.getByteTimeDomainData(data);
118 for (let i = 0; i < data.length; i++) {
119 const x = (i / data.length) * w;
120 const y = (data[i] / 255) * h;
121 i === 0 ? c.moveTo(x, y) : c.lineTo(x, y);
122 }
123 } else {
124 c.moveTo(0, h / 2);
125 c.lineTo(w, h / 2);
126 }
127 c.stroke();
128 };
129 draw();
130 return () => cancelAnimationFrame(raf);
131 }, []);
132
133 return (
134 <div
135 onPointerMove={onPointer}
136 className="rounded-3xl border border-white/15 p-6 md:p-10"
137 style={{ 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 <button
143 key={f}
144 onClick={() => play(f, KEYS[i].toUpperCase())}
145 className="rounded-2xl py-6 text-[1.6rem] font-black text-white border border-white/15 hover:scale-105 active:scale-95 transition-transform"
146 style={{
147 background: `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 {ready
156 ? `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