<?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: Giang Pham Truong</title>
    <description>The latest articles on DEV Community by Giang Pham Truong (@ptrgiang).</description>
    <link>https://dev.to/ptrgiang</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%2F4185832%2F28f2b61b-e9a4-4281-97f5-12017bf09519.jpg</url>
      <title>DEV Community: Giang Pham Truong</title>
      <link>https://dev.to/ptrgiang</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ptrgiang"/>
    <language>en</language>
    <item>
      <title>Building a browser metronome: schedule audio ahead of the UI clock</title>
      <dc:creator>Giang Pham Truong</dc:creator>
      <pubDate>Thu, 15 Oct 2026 04:16:02 +0000</pubDate>
      <link>https://dev.to/ptrgiang/building-a-browser-metronome-schedule-audio-ahead-of-the-ui-clock-283g</link>
      <guid>https://dev.to/ptrgiang/building-a-browser-metronome-schedule-audio-ahead-of-the-ui-clock-283g</guid>
      <description>&lt;p&gt;I'm building Pianory, a browser-based piano learning and practice app. One small feature has a useful implementation lesson: a metronome needs both an audio clock and a JavaScript scheduler.&lt;/p&gt;

&lt;p&gt;A repeating timer can wake up late when rendering or other JavaScript work occupies the main thread. If each callback immediately plays a click, that lateness becomes part of the rhythm. The approach in Pianory is to schedule clicks slightly ahead on the Web Audio clock.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two clocks, two jobs
&lt;/h2&gt;

&lt;p&gt;The JavaScript interval fills a short queue. &lt;code&gt;AudioContext.currentTime&lt;/code&gt; determines when the queued sounds should start.&lt;/p&gt;

&lt;p&gt;Pianory currently uses a 25 ms scheduler interval and a 120 ms lookahead window. These are implementation choices, not guarantees of uninterrupted playback: a sufficiently long stall or a suspended context can still interrupt audio.&lt;/p&gt;

&lt;p&gt;This is the core pattern, simplified from the implementation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lookahead&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.12&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// seconds&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;intervalMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;25&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;nextBeatAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentTime&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.04&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;beat&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fillSchedule&lt;/span&gt;&lt;span class="p"&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;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentTime&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nextBeatAt&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;lookahead&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;scheduleClick&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nextBeatAt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;beat&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;nextBeatAt&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;bpm&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;beat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;beat&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;beatsPerBar&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="nf"&gt;fillSchedule&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;timer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fillSchedule&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;intervalMs&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here, &lt;code&gt;scheduleClick(at, beat)&lt;/code&gt; creates the audio nodes and calls &lt;code&gt;oscillator.start(at)&lt;/code&gt;. The interval does not need to fire at the exact instant of every audible beat; it needs to replenish the queue before it runs out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make a short sound, then clean it up
&lt;/h2&gt;

&lt;p&gt;Pianory uses a sine oscillator with a short gain envelope for each click. The first beat uses 1320 Hz; the others use 880 Hz. The gain rises over about 4 ms and decays toward a small positive value by 45 ms, with the oscillator stopped at 50 ms.&lt;/p&gt;

&lt;p&gt;A small positive gain value matters when using &lt;code&gt;exponentialRampToValueAtTime&lt;/code&gt;: zero is not a valid target for an exponential ramp. Disconnecting the oscillator and gain node in &lt;code&gt;onended&lt;/code&gt; also keeps finished clicks from accumulating connections.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stop means cancel the queue too
&lt;/h2&gt;

&lt;p&gt;Clearing the interval only prevents new scheduling. It does not cancel nodes that have already been scheduled.&lt;/p&gt;

&lt;p&gt;The implementation keeps a set of scheduled oscillators. Stop clears the interval, stops those oscillators, clears pending visual-beat timers, and resets the beat counter. Dispose also closes the context.&lt;/p&gt;

&lt;p&gt;That distinction is easy to miss when testing only the Start button. A useful manual check is to press Stop immediately after Start and listen for any extra click.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep visual feedback separate
&lt;/h2&gt;

&lt;p&gt;The beat indicator uses a timeout with a delay computed from the difference between the scheduled audio time and &lt;code&gt;context.currentTime&lt;/code&gt;. This makes it approximate the audio schedule, but the indicator still depends on main-thread availability.&lt;/p&gt;

&lt;p&gt;Treat the visual pulse as feedback rather than a timing measurement. For tighter audio-to-screen alignment, a further improvement to investigate is output-latency-aware mapping with &lt;code&gt;getOutputTimestamp()&lt;/code&gt; where supported.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tempo changes have a tradeoff
&lt;/h2&gt;

&lt;p&gt;Updating BPM affects the next intervals added to the queue. It does not move clicks that are already queued. A larger lookahead tolerates more scheduler delay, but it also leaves more previously scheduled audio to handle when tempo or meter changes.&lt;/p&gt;

&lt;p&gt;For a more responsive design, consider explicitly cancelling and rebuilding the queued beats when settings change. That adds state-management work; it is a different choice from simply changing the BPM variable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test on the devices people actually practise with
&lt;/h2&gt;

&lt;p&gt;Audio should start from a user gesture. Pianory creates or resumes the context from Start and checks that it reaches the running state. Unsupported or blocked playback should leave the control stopped.&lt;/p&gt;

&lt;p&gt;My practical test checklist for this pattern is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Start, stop immediately, then restart several times.&lt;/li&gt;
&lt;li&gt;Change tempo and meter during playback.&lt;/li&gt;
&lt;li&gt;Navigate away and confirm disposal stops the sound.&lt;/li&gt;
&lt;li&gt;Try a busy main thread and a background/foreground transition.&lt;/li&gt;
&lt;li&gt;Try mobile Safari, including device audio and silent-mode behavior.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This checklist describes what to verify, not a claim that browsers guarantee background metronome playback.&lt;/p&gt;

&lt;p&gt;You can try the &lt;a href="https://pianory.app/en/app/metronome" rel="noopener noreferrer"&gt;Pianory metronome&lt;/a&gt; without an account. I'm the maker; feedback about timing, controls, or mobile audio behavior is welcome.&lt;/p&gt;

&lt;p&gt;For further reading, MDN documents &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/BaseAudioContext/currentTime" rel="noopener noreferrer"&gt;&lt;code&gt;AudioContext.currentTime&lt;/code&gt;&lt;/a&gt; and &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/AudioScheduledSourceNode/start" rel="noopener noreferrer"&gt;&lt;code&gt;AudioScheduledSourceNode.start()&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webaudio</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
