How I built a playable ocarina in the browser with Web Audio and three.js
I wanted something you could open on a phone and play — not a MIDI piano roll, not a YouTube of Zelda's Lullaby, but a real-feeling ocarina in the browser. The result is ocarina-simulator: six switchable instruments, the
I wanted something you could open on a phone and play — not a MIDI piano roll, not a YouTube of Zelda's Lullaby, but a real-feeling ocarina in the browser. The result is ocarina-simulator: six switchable instruments, the twelve Ocarina of Time songs recognised from the notes you play, a practice mode, and an optional microphone "blow" mode.
▶ Live demo: ootguide.com/ocarina/
Stack is deliberately small: TypeScript, three.js, and the Web Audio API, built with Vite. No framework, no game assets. Below is how the pieces actually work in the code — every claim maps to a file in the repo.
Five notes, named like the N64
Ocarina of Time only needs five pitches. In the simulator they keep their button names so song charts stay familiar:
| Note | N64 button | Pitch | Frequency (Hz) |
|---|---|---|---|
A |
A | D4 | 293.66 |
D |
C-Down | F4 | 349.23 |
R |
C-Right | A4 | 440 |
L |
C-Left | B4 | 493.88 |
U |
C-Up | D5 | 587.33 |
That table lives in src/ocarina/notes.ts as NOTE_FREQ. Keyboard mapping in src/main.ts mirrors the game: arrow keys for the four C-button notes, Space for A, and Shift / Z for a half step up or down while held (like R / Z targeting pitch bends in spirit).
const ARROWS: Record<string, Note> = { ArrowLeft: 'L', ArrowUp: 'U', ArrowRight: 'R', ArrowDown: 'D' };
const KEY_SHIFT: Record<string, Shift> = { KeyZ: 'flat', ShiftLeft: 'sharp', ShiftRight: 'sharp' };
On touch, you can slide a finger across the on-screen keys; pointer move swaps the held note so legato between holes feels natural.
How the sound is made
OcarinaSynth in src/ocarina/synth.ts is a monophonic voice. Each ocarina style declares either looped sample recordings or a synth recipe. When you change note while one is still held, the voice glides instead of restarting from silence — the same way covering a hole on a real ocarina retunes a continuous breath.
For styles with samples (Italian FluidR3 recordings, terracotta clay, Chinese xun), the synth picks the nearest recorded MIDI note and sets playbackRate to reach the target pitch. Loop points are baked into the style data so the sustain is seamless.
const nearest = (m: number) =>
samples.reduce((best, s) => (Math.abs(s.midi - m) < Math.abs(best.midi - m) ? s : best));
const sample = nearest(midi);
const rate = (m: number) => 2 ** ((m - sample.midi) / 12);
source.loop = true;
source.playbackRate.value = rate(midi);
For styles without recordings — Chilean black clay, bird, turtle — the fallback is a small additive voice: sine oscillators at integer harmonics, a band-passed noise burst for breath, and delayed vibrato from an LFO that ramps in over ~0.45s. Brightness is a low-pass cutoff. The default "Italian" synth recipe is { harmonics: [1, 0.1], breath: 0.025, vibrato: 0.0045, brightness: 2600 }; the turtle goes darker and airier with odd harmonics and breath: 0.14.
Everything passes through a short synthetic room: a stereo impulse of decaying, progressively darker noise into a ConvolverNode at wet mix 0.35, plus a dynamics compressor on the master bus. When a song is recognised, chime() plays a short rising triangle arpeggio (587.33 → 739.99 → 880 → 1174.66 Hz) so success feels separate from the ocarina voice.
Breath mode also bends pitch: at full blow the note goes sharp by up to 0.3 semitones (BREATH_BEND), because real ocarinas do that when you overblow.
Six ocarinas in three.js
createOcarinaScene in src/ocarina/scene.ts owns the WebGL view. Rendering setup is standard modern three.js: WebGLRenderer with ACES Filmic tone mapping, a RoomEnvironment PMREM for soft reflections, a warm key + hemisphere light, and OrbitControls (pan disabled, polar angle capped) so you can spin the instrument without losing it under the floor.
There are two build paths, selected by each style's kind in src/ocarina/styles.ts:
-
Procedural celadon (
kind: 'procedural') — a deformed sphere becomes a 10-hole Italian body (eight finger holes on top, two thumb holes underneath), with a mouthpiece cylinder and a canvas-generated crackle glaze (MeshPhysicalMaterialwith clearcoat). Holes are placed by raycasting onto the body surface. -
Scanned models (
kind: 'model') — GLBs underpublic/models/ocarina/(terracotta,chile,xun,bird,turtle), loaded withGLTFLoader+ Meshopt decoder. Hole anchors (position, outward normal, radius) are authored in the style table; bird and turtle also carry 4-holefingeringsso covered holes show as gold dots and open ones as glowing rings.
When a note sounds, setActive pulses the mesh slightly, fades hole overlays toward open/covered states, and spawns a ♪ particle that drifts upward. The animation loop pauses via IntersectionObserver when the canvas is off-screen, and respects prefers-reduced-motion.
Switching style is just scene.setStyle(style) plus synth.setSound(style.sound) — models and buffers are cached after the first load.
Song recognition is suffix matching
I deliberately kept recognition dumb and reliable. Every note you play appends to a short history (capped at 16). matchSong in src/ocarina/matcher.ts looks for the longest song whose sequence is a suffix of that history:
export function matchSong(history: readonly Note[], songs: Record<string, readonly Note[]>): string | null {
let best: string | null = null;
let bestLength = 0;
for (const [id, seq] of Object.entries(songs)) {
if (seq.length <= bestLength || seq.length > history.length) continue;
const offset = history.length - seq.length;
if (seq.every((n, i) => history[offset + i] === n)) {
best = id;
bestLength = seq.length;
}
}
return best;
}
The twelve songs live in src/songs.ts as plain note arrays — for example Zelda's Lullaby is ['L', 'U', 'R', 'L', 'U', 'R'], Bolero of Fire is eight notes, Serenade of Water is five. Songs don't share a full sequence, so "longest" only matters if a short song were ever a suffix of a longer one.
Practice mode uses the sibling helper advancePractice: the next correct note increments progress; a wrong note restarts, but still counts as progress 1 if it matches the song's opening. Deep-linking ?song=song-of-storms opens straight into that practice target.
Microphone blow mode without recording you
Blow mode is optional. BreathInput (src/ocarina/breath.ts) asks for getUserMedia with echo cancellation, noise suppression, and auto-gain off — phone call processing tends to boost speech and suppress wind noise, which is exactly the breath we want. Audio is analysed in the page and never recorded or sent.
The trick is spectral separation. The ocarina's lowest note is 293 Hz; breath energy sits lower. Eight cascaded low-pass filters at 170 Hz feed a time-domain RMS (~newest 20 ms of the analyser buffer). A second analyser (fftSize 4096) measures "peakiness" — how far the strongest harmonic in roughly 300–2000 Hz stands above its neighbourhood — so voices and music can be rejected unless they are much louder than a quiet room.
BreathDetector (src/ocarina/breath-detector.ts) turns those features into a 0–1 level:
- Calibrate room floor for the first 500 ms.
- A breath starts only if the smoothed low band stays ≥ 12 dB above the room for 100 ms without fading (a screen tap is a thump that dies ≥ 20 dB below its peak — ignored).
- A tonal spectrum needs ≥ 35 dB above the room to start, so talking doesn't trigger the note.
- Once blowing, a lower 8 dB stay threshold (tonality ignored) keeps the note going so the speaker's own ocarina tone can't cut you off.
- Level maps about 26 dB of headroom above the stay threshold onto 0.05–1, and
synth.setBreathdrives both volume and the sharp pitch bend.
When blow mode is on, fingering a hole alone is silent until you actually blow — the same split between fingers and breath as a physical instrument.
Wiring it together
src/main.ts is the page glue: held-note stack, history strip, practice select, style picker (persisted in localStorage), toast + chime on success, and lazy scene load when the widget scrolls into view. The src/ocarina/* modules have no DOM dependency, so you can import OcarinaSynth and OCARINA_STYLES into another project and call synth.noteOn('A') after a user gesture unlocks the audio context.
Models and recordings are CC BY / CC0 (listed in ASSETS.md); the code is MIT. No Nintendo audio or art is used.
Try it
Play here: ootguide.com/ocarina/. Source: github.com/zengm32343/ocarina-simulator.
The simulator sits alongside the rest of ootguide.com — an unofficial Heart Piece and Gold Skulltula tracker and walkthrough for the Ocarina of Time Remake launching November 5, 2026.
— Ming
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.