<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: ming zeng</title>
    <description>The latest articles on DEV Community by ming zeng (@ming_zeng_ed8f4121034cc4d).</description>
    <link>https://dev.to/ming_zeng_ed8f4121034cc4d</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4185034%2F859580cf-7370-4648-8906-09860d105446.png</url>
      <title>DEV Community: ming zeng</title>
      <link>https://dev.to/ming_zeng_ed8f4121034cc4d</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ming_zeng_ed8f4121034cc4d"/>
    <language>en</language>
    <item>
      <title>How I built a playable ocarina in the browser with Web Audio and three.js</title>
      <dc:creator>ming zeng</dc:creator>
      <pubDate>Wed, 14 Oct 2026 15:47:30 +0000</pubDate>
      <link>https://dev.to/ming_zeng_ed8f4121034cc4d/how-i-built-a-playable-ocarina-in-the-browser-with-web-audio-and-threejs-522b</link>
      <guid>https://dev.to/ming_zeng_ed8f4121034cc4d/how-i-built-a-playable-ocarina-in-the-browser-with-web-audio-and-threejs-522b</guid>
      <description>&lt;p&gt;I wanted something you could open on a phone and &lt;em&gt;play&lt;/em&gt; — not a MIDI piano roll, not a YouTube of Zelda's Lullaby, but a real-feeling ocarina in the browser. The result is &lt;a href="https://github.com/zengm32343/ocarina-simulator" rel="noopener noreferrer"&gt;ocarina-simulator&lt;/a&gt;: six switchable instruments, the twelve &lt;em&gt;Ocarina of Time&lt;/em&gt; songs recognised from the notes you play, a practice mode, and an optional microphone "blow" mode.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;▶ Live demo: &lt;a href="https://ootguide.com/ocarina/" rel="noopener noreferrer"&gt;ootguide.com/ocarina/&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Stack is deliberately small: TypeScript, &lt;a href="https://threejs.org/" rel="noopener noreferrer"&gt;three.js&lt;/a&gt;, 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five notes, named like the N64
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;Ocarina of Time&lt;/em&gt; only needs five pitches. In the simulator they keep their button names so song charts stay familiar:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Note&lt;/th&gt;
&lt;th&gt;N64 button&lt;/th&gt;
&lt;th&gt;Pitch&lt;/th&gt;
&lt;th&gt;Frequency (Hz)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;A&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A&lt;/td&gt;
&lt;td&gt;D4&lt;/td&gt;
&lt;td&gt;293.66&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;D&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;C-Down&lt;/td&gt;
&lt;td&gt;F4&lt;/td&gt;
&lt;td&gt;349.23&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;R&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;C-Right&lt;/td&gt;
&lt;td&gt;A4&lt;/td&gt;
&lt;td&gt;440&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;L&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;C-Left&lt;/td&gt;
&lt;td&gt;B4&lt;/td&gt;
&lt;td&gt;493.88&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;U&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;C-Up&lt;/td&gt;
&lt;td&gt;D5&lt;/td&gt;
&lt;td&gt;587.33&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That table lives in &lt;code&gt;src/ocarina/notes.ts&lt;/code&gt; as &lt;code&gt;NOTE_FREQ&lt;/code&gt;. Keyboard mapping in &lt;code&gt;src/main.ts&lt;/code&gt; 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).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ARROWS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Note&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ArrowLeft&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;L&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ArrowUp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;U&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ArrowRight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;R&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ArrowDown&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;D&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;KEY_SHIFT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Shift&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;KeyZ&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;flat&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ShiftLeft&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sharp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ShiftRight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sharp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On touch, you can slide a finger across the on-screen keys; pointer move swaps the held note so legato between holes feels natural.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the sound is made
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;OcarinaSynth&lt;/code&gt; in &lt;code&gt;src/ocarina/synth.ts&lt;/code&gt; is a monophonic voice. Each ocarina style declares either &lt;strong&gt;looped sample recordings&lt;/strong&gt; or a &lt;strong&gt;synth recipe&lt;/strong&gt;. When you change note while one is still held, the voice &lt;em&gt;glides&lt;/em&gt; instead of restarting from silence — the same way covering a hole on a real ocarina retunes a continuous breath.&lt;/p&gt;

&lt;p&gt;For styles with samples (Italian FluidR3 recordings, terracotta clay, Chinese xun), the synth picks the nearest recorded MIDI note and sets &lt;code&gt;playbackRate&lt;/code&gt; to reach the target pitch. Loop points are baked into the style data so the sustain is seamless.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nearest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;samples&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;best&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;midi&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;best&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;midi&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;best&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sample&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;nearest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;midi&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;sample&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;midi&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;loop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;playbackRate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;midi&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;code&gt;{ harmonics: [1, 0.1], breath: 0.025, vibrato: 0.0045, brightness: 2600 }&lt;/code&gt;; the turtle goes darker and airier with odd harmonics and &lt;code&gt;breath: 0.14&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Everything passes through a short synthetic room: a stereo impulse of decaying, progressively darker noise into a &lt;code&gt;ConvolverNode&lt;/code&gt; at wet mix &lt;code&gt;0.35&lt;/code&gt;, plus a dynamics compressor on the master bus. When a song is recognised, &lt;code&gt;chime()&lt;/code&gt; plays a short rising triangle arpeggio (&lt;code&gt;587.33 → 739.99 → 880 → 1174.66&lt;/code&gt; Hz) so success feels separate from the ocarina voice.&lt;/p&gt;

&lt;p&gt;Breath mode also bends pitch: at full blow the note goes sharp by up to &lt;code&gt;0.3&lt;/code&gt; semitones (&lt;code&gt;BREATH_BEND&lt;/code&gt;), because real ocarinas do that when you overblow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Six ocarinas in three.js
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;createOcarinaScene&lt;/code&gt; in &lt;code&gt;src/ocarina/scene.ts&lt;/code&gt; owns the WebGL view. Rendering setup is standard modern three.js: &lt;code&gt;WebGLRenderer&lt;/code&gt; with ACES Filmic tone mapping, a &lt;code&gt;RoomEnvironment&lt;/code&gt; PMREM for soft reflections, a warm key + hemisphere light, and &lt;code&gt;OrbitControls&lt;/code&gt; (pan disabled, polar angle capped) so you can spin the instrument without losing it under the floor.&lt;/p&gt;

&lt;p&gt;There are two build paths, selected by each style's &lt;code&gt;kind&lt;/code&gt; in &lt;code&gt;src/ocarina/styles.ts&lt;/code&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Procedural celadon&lt;/strong&gt; (&lt;code&gt;kind: 'procedural'&lt;/code&gt;) — 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 (&lt;code&gt;MeshPhysicalMaterial&lt;/code&gt; with clearcoat). Holes are placed by raycasting onto the body surface.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scanned models&lt;/strong&gt; (&lt;code&gt;kind: 'model'&lt;/code&gt;) — GLBs under &lt;code&gt;public/models/ocarina/&lt;/code&gt; (&lt;code&gt;terracotta&lt;/code&gt;, &lt;code&gt;chile&lt;/code&gt;, &lt;code&gt;xun&lt;/code&gt;, &lt;code&gt;bird&lt;/code&gt;, &lt;code&gt;turtle&lt;/code&gt;), loaded with &lt;code&gt;GLTFLoader&lt;/code&gt; + Meshopt decoder. Hole anchors (position, outward normal, radius) are authored in the style table; bird and turtle also carry 4-hole &lt;code&gt;fingerings&lt;/code&gt; so covered holes show as gold dots and open ones as glowing rings.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;When a note sounds, &lt;code&gt;setActive&lt;/code&gt; pulses the mesh slightly, fades hole overlays toward open/covered states, and spawns a ♪ particle that drifts upward. The animation loop pauses via &lt;code&gt;IntersectionObserver&lt;/code&gt; when the canvas is off-screen, and respects &lt;code&gt;prefers-reduced-motion&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Switching style is just &lt;code&gt;scene.setStyle(style)&lt;/code&gt; plus &lt;code&gt;synth.setSound(style.sound)&lt;/code&gt; — models and buffers are cached after the first load.&lt;/p&gt;

&lt;h2&gt;
  
  
  Song recognition is suffix matching
&lt;/h2&gt;

&lt;p&gt;I deliberately kept recognition dumb and reliable. Every note you play appends to a short history (capped at 16). &lt;code&gt;matchSong&lt;/code&gt; in &lt;code&gt;src/ocarina/matcher.ts&lt;/code&gt; looks for the &lt;strong&gt;longest&lt;/strong&gt; song whose sequence is a suffix of that history:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;matchSong&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;history&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;Note&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;songs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;Note&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;best&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;bestLength&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;seq&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;songs&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;bestLength&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;seq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;history&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;history&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;seq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;every&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;history&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;bestLength&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;seq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;best&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The twelve songs live in &lt;code&gt;src/songs.ts&lt;/code&gt; as plain note arrays — for example Zelda's Lullaby is &lt;code&gt;['L', 'U', 'R', 'L', 'U', 'R']&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;Practice mode uses the sibling helper &lt;code&gt;advancePractice&lt;/code&gt;: the next correct note increments progress; a wrong note restarts, but still counts as progress &lt;code&gt;1&lt;/code&gt; if it matches the song's opening. Deep-linking &lt;code&gt;?song=song-of-storms&lt;/code&gt; opens straight into that practice target.&lt;/p&gt;

&lt;h2&gt;
  
  
  Microphone blow mode without recording you
&lt;/h2&gt;

&lt;p&gt;Blow mode is optional. &lt;code&gt;BreathInput&lt;/code&gt; (&lt;code&gt;src/ocarina/breath.ts&lt;/code&gt;) asks for &lt;code&gt;getUserMedia&lt;/code&gt; with echo cancellation, noise suppression, and auto-gain &lt;strong&gt;off&lt;/strong&gt; — 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.&lt;/p&gt;

&lt;p&gt;The trick is spectral separation. The ocarina's lowest note is 293 Hz; breath energy sits lower. Eight cascaded low-pass filters at &lt;strong&gt;170 Hz&lt;/strong&gt; feed a time-domain RMS (~newest 20 ms of the analyser buffer). A second analyser (&lt;code&gt;fftSize&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;BreathDetector&lt;/code&gt; (&lt;code&gt;src/ocarina/breath-detector.ts&lt;/code&gt;) turns those features into a 0–1 level:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Calibrate room floor for the first 500 ms.&lt;/li&gt;
&lt;li&gt;A breath &lt;strong&gt;starts&lt;/strong&gt; 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).&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;tonal&lt;/strong&gt; spectrum needs ≥ 35 dB above the room to start, so talking doesn't trigger the note.&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;li&gt;Level maps about 26 dB of headroom above the stay threshold onto 0.05–1, and &lt;code&gt;synth.setBreath&lt;/code&gt; drives both volume and the sharp pitch bend.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring it together
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;src/main.ts&lt;/code&gt; is the page glue: held-note stack, history strip, practice select, style picker (persisted in &lt;code&gt;localStorage&lt;/code&gt;), toast + chime on success, and lazy scene load when the widget scrolls into view. The &lt;code&gt;src/ocarina/*&lt;/code&gt; modules have no DOM dependency, so you can import &lt;code&gt;OcarinaSynth&lt;/code&gt; and &lt;code&gt;OCARINA_STYLES&lt;/code&gt; into another project and call &lt;code&gt;synth.noteOn('A')&lt;/code&gt; after a user gesture unlocks the audio context.&lt;/p&gt;

&lt;p&gt;Models and recordings are CC BY / CC0 (listed in &lt;code&gt;ASSETS.md&lt;/code&gt;); the code is MIT. No Nintendo audio or art is used.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Play here: &lt;strong&gt;&lt;a href="https://ootguide.com/ocarina/" rel="noopener noreferrer"&gt;ootguide.com/ocarina/&lt;/a&gt;&lt;/strong&gt;. Source: &lt;strong&gt;&lt;a href="https://github.com/zengm32343/ocarina-simulator" rel="noopener noreferrer"&gt;github.com/zengm32343/ocarina-simulator&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The simulator sits alongside the rest of &lt;a href="https://ootguide.com/" rel="noopener noreferrer"&gt;ootguide.com&lt;/a&gt; — an unofficial Heart Piece and Gold Skulltula tracker and walkthrough for the &lt;em&gt;Ocarina of Time&lt;/em&gt; Remake launching November 5, 2026.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;— Ming&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>threejs</category>
      <category>gamedev</category>
    </item>
  </channel>
</rss>
