The concept
WAVECRAFT is a product page for a fictional software synthesizer where the hero section is the product: a fully playable eight-voice polysynth built on the Web Audio API. If a synth claims to be instant and installation-free, the honest way to sell it is to let you play it before you've scrolled.
The visual language is neumorphism — the "soft UI" school that flared up around 2020, in which every control appears extruded from, or pressed into, a single material. It has a deserved reputation for poor contrast and mushy affordances, so this page treats it as a discipline rather than a filter: one ground color, one physically consistent light source (top-left), one accent, and real state changes — raised means idle, inset means active, always. It happens to be the perfect skin for a synthesizer, an object whose entire charm is molded plastic and knobs that resist your fingers.
The double-shadow recipe
All of the depth comes from one trick: a pale shadow toward the light and a dark shadow away from it, on a background that sits between them. These are the exact values used everywhere on this site:
:root {
--bg: #e4e8f0; /* the single material everything is made of */
--hi: #ffffff; /* light source, top-left */
--lo: #b8bfcc; /* occlusion, bottom-right */
}
/* raised — idle controls */
box-shadow: -6px -6px 14px var(--hi), 6px 6px 14px var(--lo);
/* inset — pressed / active states (same colors, swapped corners) */
box-shadow: inset 4px 4px 9px var(--lo), inset -4px -4px 9px var(--hi);
Three rules keep it from going mushy. Blur ≈ 2× offset (6px offset, 14px blur) reads as soft plastic; equal blur and offset reads as a sticker. Element and ground share one color — the moment a card gets its own background, the extrusion illusion dies. And depth is hierarchy: big slabs get the full shadow, chips and keys get a half-size version (-3px -3px 7px / 3px 3px 7px), and anything currently active inverts to inset.
The knob drag math
The knobs are plain divs with role="slider". Dragging is vertical — grab and pull up, like real studio gear — and the needle's rotation is decoupled from the raw value through a small lerp run in requestAnimationFrame, so the needle glides even when the pointer jitters:
el.addEventListener('pointerdown', e => {
dragging = true; startY = e.clientY; startVal = k.val;
el.setPointerCapture(e.pointerId); // drag keeps working off-element
});
el.addEventListener('pointermove', e => {
if (!dragging) return;
setKnob(k, startVal + (startY - e.clientY) / 150); // 150px = full sweep
});
// every frame: ease the shown value toward the target, then rotate
k.shown += (k.val - k.shown) * 0.22;
needle.style.transform = 'rotate(' + (-135 + k.shown * 270) + 'deg)';
The 270° sweep maps value 0 → −135° and value 1 → +135°, the classic hardware pot throw. Arrow keys nudge the same setKnob() path (±2%, ±10% with Shift, Home/End for the rails), and every change updates aria-valuenow plus a human aria-valuetext like "2.1 kHz". Parameter mapping is perceptual, not linear: cutoff runs 80 × 150^v Hz so equal drag feels like equal pitch change.
The audio graph
Each held key builds its own little chain — oscillator, filter, envelope — and pours into a shared master bus. The analyser taps the summed signal right before the speakers, which is what the oscilloscope draws.
One chain per held note; everything to the right of the bracket exists exactly once.
The AudioContext is created only inside the power button's click handler — never at load — because browsers rightly refuse autoplaying audio. The power button doubles as the status LED, and every audio call sits in try/catch so a blocked or torn-down context degrades to silence instead of console noise.
Voice management
Polyphony is a Map of MIDI note → voice. The two failure modes of naive Web Audio synths are leaks (nodes never disconnected) and zombie voices (notes stuck on when a tab blurs mid-chord). Both are handled at the voice's birth:
// birth: cleanup is scheduled before the first sample plays
osc.onended = () => { osc.disconnect(); filt.disconnect(); env.disconnect(); };
osc.start(t);
voices.set(midi, { osc, filt, env, born: t });
// death: exponential release, then a hard stop that triggers onended
env.gain.setValueAtTime(Math.max(env.gain.value, 0.0001), t);
env.gain.exponentialRampToValueAtTime(0.0001, t + rel);
osc.stop(t + rel + 0.06);
// a 9th note? the oldest voice gets a 30 ms fade and its key un-lights
if (voices.size >= 8) stealOldest();
Ramping to 0.0001 before stopping (rather than to zero, which exponentialRamp forbids) kills the click you'd get from a hard cut. A visibilitychange listener releases everything when the tab hides, and a window blur listener catches the Cmd-Tab-while-holding-a-chord case that keyup never sees.
The oscilloscope
analyser.getByteTimeDomainData(buf); // 2048 samples of what you hear
for (let i = 0; i < buf.length; i++) {
const x = (i / (buf.length - 1)) * w;
const y = (buf[i] / 255) * h; // 128 = silence = center line
i ? ctx.lineTo(x, y) : ctx.moveTo(x, y);
}
Because the analyser sits after the master bus, the scope shows the mix — play a chord and you see the interference pattern, sweep the filter and you watch the corners round off in real time. It's the cheapest honest visualizer on the web.
The keyboard mapping
| Row | Keys | Notes |
|---|---|---|
| Home row | A S D F G H J K then L ; ' | white keys, C4 → F5 |
| Top row | W E T Y U then O P | black keys, C♯4 → D♯5 |
The same press() / release() pair serves the computer keyboard, pointer events (with multi-touch and glissando — slide a finger across the tray), the preset arpeggiator, and Enter/Space on a focused key, so the on-screen key visuals can never drift out of sync with the audio.
How it was made
This site was built by Claude (Fable 5) writing vanilla HTML, CSS, and JavaScript by hand — no frameworks, no build step, no audio libraries. Two files, one typeface, and the Web Audio API as it ships in every modern browser.
It's one of 25 sites in the Fable Showcase, each built in a deliberately different visual school. See the rest at fable-25-dhb.pages.dev.
Steal this
- The two-shadow token pair. Define
--raiseand--insetonce as custom properties and express every interactive state as one or the other. Your hover/active/checked CSS collapses to swapping a variable. - Vertical drag for rotary values.
(startY − clientY) / 150plus pointer capture is the entire gesture. It beats circular drag math in feel and in code, and it works identically for touch. - Lerp the needle, not the value. Store the true value instantly, but display
shown += (val − shown) × 0.22per frame. Controls feel machined instead of twitchy, and the audio still responds immediately. - Schedule the funeral at the birth. Set
osc.onendedto disconnect the whole voice chain before you ever callstart(). Node leaks become structurally impossible instead of something you remember to fix. - Treat blur and hidden as note-off. Any page holding a resource on keydown — audio, pointer lock, a drag — must release it on
window.blurandvisibilitychange. Users forgive a cut-off note; they don't forgive a drone from a background tab.