Code — how this page is built
Under the hood
The actual source of the components on this page, read straight from the repo. Copy it, read it, or open it on GitHub.
ThereminGameview on GitHub ↗
Lazily creates a Web Audio oscillator→gain graph on first interaction; maps pointer X→frequency (log) and Y→gain with smoothing, and offers four oscillator waveforms.
'use client';
import * as React from 'react';
const NOTE_NAMES = ['C', 'C♯', 'D', 'D♯', 'E', 'F', 'F♯', 'G', 'G♯', 'A', 'A♯', 'B'];
function noteName(freq: number) {
const n = Math.round(12 * Math.log2(freq / 440) + 69);
return `${NOTE_NAMES[((n % 12) + 12) % 12]}${Math.floor(n / 12) - 1}`;
}
export function ThereminGame() {
const padRef = React.useRef<HTMLDivElement>(null);
const audio = React.useRef<{ ctx: AudioContext; osc: OscillatorNode; gain: GainNode } | null>(null);
const [wave, setWave] = React.useState<OscillatorType>('sine');
const [pos, setPos] = React.useState<{ x: number; y: number } | null>(null);
const [freq, setFreq] = React.useState(0);
const ensure = () => {
if (audio.current) return audio.current;
const Ctx = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
const ctx = new Ctx();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
gain.gain.value = 0;
osc.type = wave;
osc.connect(gain).connect(ctx.destination);
osc.start();
audio.current = { ctx, osc, gain };
return audio.current;
};
const update = (e: React.PointerEvent, on: boolean) => {
const a = ensure();
const rect = padRef.current!.getBoundingClientRect();
const x = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
const y = Math.min(1, Math.max(0, (e.clientY - rect.top) / rect.height));
const f = 110 * Math.pow(2, x * 3); // 110 Hz → ~880 Hz over 3 octaves
a.osc.type = wave;
a.osc.frequency.setTargetAtTime(f, a.ctx.currentTime, 0.02);
a.gain.gain.setTargetAtTime(on ? (1 - y) * 0.28 : 0, a.ctx.currentTime, 0.03);
setFreq(f);
setPos({ x, y });
};
const stop = () => {
if (audio.current) audio.current.gain.gain.setTargetAtTime(0, audio.current.ctx.currentTime, 0.05);
setPos(null);
};
React.useEffect(() => () => { audio.current?.ctx.close(); }, []);
return (
<div className="flex w-full max-w-[560px] flex-col items-center gap-4">
<div
ref={padRef}
onPointerDown={(e) => { padRef.current?.setPointerCapture(e.pointerId); update(e, true); }}
onPointerMove={(e) => { if (pos) update(e, true); }}
onPointerUp={stop}
onPointerLeave={stop}
className="relative h-72 w-full touch-none overflow-hidden rounded-xl"
style={{ background: 'linear-gradient(120deg,#14110b,#2a2418)', cursor: 'crosshair' }}
>
{pos ? (
<div
className="pointer-events-none absolute h-6 w-6 -translate-x-1/2 -translate-y-1/2 rounded-full bg-[var(--accent-blue)] shadow-[0_0_24px_var(--accent-blue)]"
style={{ left: `${pos.x * 100}%`, top: `${pos.y * 100}%` }}
/>
) : (
<div className="absolute inset-0 flex items-center justify-center font-mono text-sm text-white/40">
press and drag to play
</div>
)}
</div>
<div className="flex items-center gap-3">
{(['sine', 'triangle', 'sawtooth', 'square'] as OscillatorType[]).map((w) => (
<button
key={w}
onClick={() => setWave(w)}
className={`font-mono text-[11px] uppercase tracking-[0.1em] ${wave === w ? 'font-bold underline' : 'opacity-50'}`}
>
{w}
</button>
))}
</div>
<div className="font-mono text-[11px] uppercase tracking-[0.12em] opacity-55">
{pos ? `${Math.round(freq)} Hz · ${noteName(freq)}` : 'X = pitch · Y = volume'}
</div>
</div>
);
}