<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Ming's Dev Blog]]></title><description><![CDATA[Ming's Dev Blog]]></description><link>https://mingzeng.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Mon, 26 Oct 2026 23:59:05 GMT</lastBuildDate><atom:link href="https://mingzeng.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[How I built a playable ocarina in the browser with Web Audio and three.js]]></title><description><![CDATA[How I built a 3D ocarina in the browser: Web Audio synthesis, six three.js models, Ocarina of Time song recognition and mic blow detection.]]></description><link>https://mingzeng.hashnode.dev/how-i-built-a-playable-ocarina-in-the-browser-with-web-audio-and-three-js</link><guid isPermaLink="true">https://mingzeng.hashnode.dev/how-i-built-a-playable-ocarina-in-the-browser-with-web-audio-and-three-js</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[ThreeJS]]></category><category><![CDATA[Web Audio]]></category><category><![CDATA[GameDev]]></category><dc:creator><![CDATA[zengm32343]]></dc:creator><pubDate>Wed, 14 Oct 2026 16:02:53 GMT</pubDate><content:encoded><![CDATA[<p>I wanted something you could open on a phone and <em>play</em> — not a MIDI piano roll, not a YouTube of Zelda's Lullaby, but a real-feeling ocarina in the browser. The result is <a href="https://github.com/zengm32343/ocarina-simulator">ocarina-simulator</a>: six switchable instruments, the twelve <em>Ocarina of Time</em> songs recognised from the notes you play, a practice mode, and an optional microphone "blow" mode.</p>
<p><strong>▶ Live demo:</strong> <a href="https://ootguide.com/ocarina/"><strong>ootguide.com/ocarina/</strong></a></p>
<p>Stack is deliberately small: TypeScript, <a href="https://threejs.org/">three.js</a>, 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.</p>
<h2>Five notes, named like the N64</h2>
<p><em>Ocarina of Time</em> only needs five pitches. In the simulator they keep their button names so song charts stay familiar:</p>
<table>
<thead>
<tr>
<th>Note</th>
<th>N64 button</th>
<th>Pitch</th>
<th>Frequency (Hz)</th>
</tr>
</thead>
<tbody><tr>
<td><code>A</code></td>
<td>A</td>
<td>D4</td>
<td>293.66</td>
</tr>
<tr>
<td><code>D</code></td>
<td>C-Down</td>
<td>F4</td>
<td>349.23</td>
</tr>
<tr>
<td><code>R</code></td>
<td>C-Right</td>
<td>A4</td>
<td>440</td>
</tr>
<tr>
<td><code>L</code></td>
<td>C-Left</td>
<td>B4</td>
<td>493.88</td>
</tr>
<tr>
<td><code>U</code></td>
<td>C-Up</td>
<td>D5</td>
<td>587.33</td>
</tr>
</tbody></table>
<p>That table lives in <code>src/ocarina/notes.ts</code> as <code>NOTE_FREQ</code>. Keyboard mapping in <code>src/main.ts</code> 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).</p>
<pre><code class="language-ts">const ARROWS: Record&lt;string, Note&gt; = { ArrowLeft: 'L', ArrowUp: 'U', ArrowRight: 'R', ArrowDown: 'D' };
const KEY_SHIFT: Record&lt;string, Shift&gt; = { KeyZ: 'flat', ShiftLeft: 'sharp', ShiftRight: 'sharp' };
</code></pre>
<p>On touch, you can slide a finger across the on-screen keys; pointer move swaps the held note so legato between holes feels natural.</p>
<h2>How the sound is made</h2>
<p><code>OcarinaSynth</code> in <code>src/ocarina/synth.ts</code> is a monophonic voice. Each ocarina style declares either <strong>looped sample recordings</strong> or a <strong>synth recipe</strong>. When you change note while one is still held, the voice <em>glides</em> instead of restarting from silence — the same way covering a hole on a real ocarina retunes a continuous breath.</p>
<p>For styles with samples (Italian FluidR3 recordings, terracotta clay, Chinese xun), the synth picks the nearest recorded MIDI note and sets <code>playbackRate</code> to reach the target pitch. Loop points are baked into the style data so the sustain is seamless.</p>
<pre><code class="language-ts">const nearest = (m: number) =&gt;
  samples.reduce((best, s) =&gt; (Math.abs(s.midi - m) &lt; Math.abs(best.midi - m) ? s : best));
const sample = nearest(midi);
const rate = (m: number) =&gt; 2 ** ((m - sample.midi) / 12);
source.loop = true;
source.playbackRate.value = rate(midi);
</code></pre>
<p>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 <code>{ harmonics: [1, 0.1], breath: 0.025, vibrato: 0.0045, brightness: 2600 }</code>; the turtle goes darker and airier with odd harmonics and <code>breath: 0.14</code>.</p>
<p>Everything passes through a short synthetic room: a stereo impulse of decaying, progressively darker noise into a <code>ConvolverNode</code> at wet mix <code>0.35</code>, plus a dynamics compressor on the master bus. When a song is recognised, <code>chime()</code> plays a short rising triangle arpeggio (<code>587.33 → 739.99 → 880 → 1174.66</code> Hz) so success feels separate from the ocarina voice.</p>
<p>Breath mode also bends pitch: at full blow the note goes sharp by up to <code>0.3</code> semitones (<code>BREATH_BEND</code>), because real ocarinas do that when you overblow.</p>
<h2>Six ocarinas in three.js</h2>
<p><code>createOcarinaScene</code> in <code>src/ocarina/scene.ts</code> owns the WebGL view. Rendering setup is standard modern three.js: <code>WebGLRenderer</code> with ACES Filmic tone mapping, a <code>RoomEnvironment</code> PMREM for soft reflections, a warm key + hemisphere light, and <code>OrbitControls</code> (pan disabled, polar angle capped) so you can spin the instrument without losing it under the floor.</p>
<p>There are two build paths, selected by each style's <code>kind</code> in <code>src/ocarina/styles.ts</code>:</p>
<ol>
<li><p><strong>Procedural celadon</strong> (<code>kind: 'procedural'</code>) — 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 (<code>MeshPhysicalMaterial</code> with clearcoat). Holes are placed by raycasting onto the body surface.</p>
</li>
<li><p><strong>Scanned models</strong> (<code>kind: 'model'</code>) — GLBs under <code>public/models/ocarina/</code> (<code>terracotta</code>, <code>chile</code>, <code>xun</code>, <code>bird</code>, <code>turtle</code>), loaded with <code>GLTFLoader</code> + Meshopt decoder. Hole anchors (position, outward normal, radius) are authored in the style table; bird and turtle also carry 4-hole <code>fingerings</code> so covered holes show as gold dots and open ones as glowing rings.</p>
</li>
</ol>
<p>When a note sounds, <code>setActive</code> pulses the mesh slightly, fades hole overlays toward open/covered states, and spawns a ♪ particle that drifts upward. The animation loop pauses via <code>IntersectionObserver</code> when the canvas is off-screen, and respects <code>prefers-reduced-motion</code>.</p>
<p>Switching style is just <code>scene.setStyle(style)</code> plus <code>synth.setSound(style.sound)</code> — models and buffers are cached after the first load.</p>
<h2>Song recognition is suffix matching</h2>
<p>I deliberately kept recognition dumb and reliable. Every note you play appends to a short history (capped at 16). <code>matchSong</code> in <code>src/ocarina/matcher.ts</code> looks for the <strong>longest</strong> song whose sequence is a suffix of that history:</p>
<pre><code class="language-ts">export function matchSong(history: readonly Note[], songs: Record&lt;string, readonly Note[]&gt;): string | null {
  let best: string | null = null;
  let bestLength = 0;
  for (const [id, seq] of Object.entries(songs)) {
    if (seq.length &lt;= bestLength || seq.length &gt; history.length) continue;
    const offset = history.length - seq.length;
    if (seq.every((n, i) =&gt; history[offset + i] === n)) {
      best = id;
      bestLength = seq.length;
    }
  }
  return best;
}
</code></pre>
<p>The twelve songs live in <code>src/songs.ts</code> as plain note arrays — for example Zelda's Lullaby is <code>['L', 'U', 'R', 'L', 'U', 'R']</code>, 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.</p>
<p>Practice mode uses the sibling helper <code>advancePractice</code>: the next correct note increments progress; a wrong note restarts, but still counts as progress <code>1</code> if it matches the song's opening. Deep-linking <code>?song=song-of-storms</code> opens straight into that practice target.</p>
<h2>Microphone blow mode without recording you</h2>
<p>Blow mode is optional. <code>BreathInput</code> (<code>src/ocarina/breath.ts</code>) asks for <code>getUserMedia</code> with echo cancellation, noise suppression, and auto-gain <strong>off</strong> — 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.</p>
<p>The trick is spectral separation. The ocarina's lowest note is 293 Hz; breath energy sits lower. Eight cascaded low-pass filters at <strong>170 Hz</strong> feed a time-domain RMS (~newest 20 ms of the analyser buffer). A second analyser (<code>fftSize</code> 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.</p>
<p><code>BreathDetector</code> (<code>src/ocarina/breath-detector.ts</code>) turns those features into a 0–1 level:</p>
<ul>
<li><p>Calibrate room floor for the first 500 ms.</p>
</li>
<li><p>A breath <strong>starts</strong> 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).</p>
</li>
<li><p>A <strong>tonal</strong> spectrum needs ≥ 35 dB above the room to start, so talking doesn't trigger the note.</p>
</li>
<li><p>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.</p>
</li>
<li><p>Level maps about 26 dB of headroom above the stay threshold onto 0.05–1, and <code>synth.setBreath</code> drives both volume and the sharp pitch bend.</p>
</li>
</ul>
<p>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.</p>
<h2>Wiring it together</h2>
<p><code>src/main.ts</code> is the page glue: held-note stack, history strip, practice select, style picker (persisted in <code>localStorage</code>), toast + chime on success, and lazy scene load when the widget scrolls into view. The <code>src/ocarina/*</code> modules have no DOM dependency, so you can import <code>OcarinaSynth</code> and <code>OCARINA_STYLES</code> into another project and call <code>synth.noteOn('A')</code> after a user gesture unlocks the audio context.</p>
<p>Models and recordings are CC BY / CC0 (listed in <code>ASSETS.md</code>); the code is MIT. No Nintendo audio or art is used.</p>
<h2>Try it</h2>
<p>Play here: <a href="https://ootguide.com/ocarina/"><strong>ootguide.com/ocarina/</strong></a>. Source: <a href="https://github.com/zengm32343/ocarina-simulator"><strong>github.com/zengm32343/ocarina-simulator</strong></a>.</p>
<p>The simulator sits alongside the rest of <a href="https://ootguide.com/">ootguide.com</a> — an unofficial Heart Piece and Gold Skulltula tracker and walkthrough for the <em>Ocarina of Time</em> Remake launching November 5, 2026.</p>
<p><em>— Ming</em></p>
]]></content:encoded></item></channel></rss>