<?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: nayeem</title>
    <description>The latest articles on DEV Community by nayeem (@nayeem_software).</description>
    <link>https://dev.to/nayeem_software</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%2F4176734%2Fb4c5d891-84dd-477a-8a65-5ed9e5dc8ef1.png</url>
      <title>DEV Community: nayeem</title>
      <link>https://dev.to/nayeem_software</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nayeem_software"/>
    <language>en</language>
    <item>
      <title>Rebuilding Apple Watch's "Water Lock" Tone in the Browser (and 3 iOS Safari Gotchas)</title>
      <dc:creator>nayeem</dc:creator>
      <pubDate>Sat, 24 Oct 2026 08:50:11 +0000</pubDate>
      <link>https://dev.to/nayeem_software/rebuilding-apple-watchs-water-lock-tone-in-the-browser-and-3-ios-safari-gotchas-d0b</link>
      <guid>https://dev.to/nayeem_software/rebuilding-apple-watchs-water-lock-tone-in-the-browser-and-3-ios-safari-gotchas-d0b</guid>
      <description>&lt;p&gt;When you finish a swim, Apple Watch can play a short, buzzy tone to push water out of its speaker. Phones don't have a button like that. Most "water eject" apps I found play a similar tone, and some put it behind a weekly subscription.&lt;/p&gt;

&lt;p&gt;I wanted to know if a plain web page could do the same job on any phone, with nothing to install. It mostly can. Here's the physics, the code, and three iOS Safari problems I hit along the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a low tone, and why around 165 Hz
&lt;/h2&gt;

&lt;p&gt;A speaker grille is a fine mesh. A droplet in the mesh is held there by surface tension, so it often won't drain out on its own.&lt;/p&gt;

&lt;p&gt;The speaker sits right behind that mesh. At low pitches the speaker cone moves further with each cycle, so it pushes more air against the droplet. At high pitches it barely moves.&lt;/p&gt;

&lt;p&gt;There's a limit, though. Small phone speakers can't play deep bass well. Their output drops off sharply below roughly 100–200 Hz, depending on the phone. So a tone in the 145–165 Hz range is a practical middle ground: low enough to move the cone a lot, high enough that the phone can still play it loudly. Treat that as a reasonable starting point, not a measured optimum for every phone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pulses instead of a steady tone
&lt;/h2&gt;

&lt;p&gt;With a steady tone, the droplet tends to settle into a new shape and stay put. Short bursts with gaps keep shaking it. I use about 0.8 s on and 0.2 s off.&lt;/p&gt;

&lt;p&gt;Here's the core of it in plain JavaScript:&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;function&lt;/span&gt; &lt;span class="nf"&gt;playWaterPulse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seconds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;freq&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;165&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;AudioCtx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;AudioContext&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webkitAudioContext&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;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AudioCtx&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;osc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createOscillator&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;gain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createGain&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="nx"&gt;osc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sine&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;osc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;frequency&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="nx"&gt;freq&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;gain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gain&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;osc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;gain&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;destination&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;t0&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ctx&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;for &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;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;t0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;t0&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;t&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="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Short ramps instead of hard on/off, so the speaker doesn't click&lt;/span&gt;
    &lt;span class="nx"&gt;gain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setTargetAtTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.015&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;gain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setTargetAtTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.015&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;osc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;osc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t0&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return &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="nx"&gt;osc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Call it from a button's click handler (browsers block audio that starts without a tap). The returned function stops it early.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha 1: the iPhone silent switch mutes web audio
&lt;/h2&gt;

&lt;p&gt;On iOS, web audio is treated like a game sound. If the phone is set to silent, the page plays nothing. Someone with a wet phone will just assume the page is broken.&lt;/p&gt;

&lt;p&gt;Safari 17 added the Audio Session API. Setting the type to &lt;code&gt;"playback"&lt;/code&gt; before starting tells iOS this is media, so it plays even in silent mode:&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;audioSession&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;audioSession&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;playback&lt;/span&gt;&lt;span class="dl"&gt;"&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;On older iOS versions this doesn't exist, so I still show a note telling people to check the silent switch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha 2: the first tap sometimes plays nothing
&lt;/h2&gt;

&lt;p&gt;On some iPhones the first tap after loading created the audio context but no sound came out. What fixed it for me was playing a one-sample silent buffer right inside the tap handler, before anything async runs:&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;function&lt;/span&gt; &lt;span class="nf"&gt;unlock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&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;buf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createBuffer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;22050&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;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createBufferSource&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;buffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;suspended&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resume&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 important part is the timing. If you &lt;code&gt;await&lt;/code&gt; anything first, Safari may no longer count it as a user gesture.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha 3: vibration only works on Android
&lt;/h2&gt;

&lt;p&gt;Shaking the phone along with the sound seemed like a nice extra. &lt;code&gt;navigator.vibrate()&lt;/code&gt; works in Chrome on Android:&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;vibrate&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;vibrate&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mi"&gt;800&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;800&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;800&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;200&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;Safari on iPhone doesn't support the Vibration API, and the call quietly does nothing. I label the toggle "Android only" so iPhone users don't think something is wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this can't do
&lt;/h2&gt;

&lt;p&gt;This part matters more than the code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It can only help with water sitting in the speaker grille. It does nothing for water inside the phone.&lt;/li&gt;
&lt;li&gt;Salt water, pool water and sugary drinks can leave residue or cause corrosion. Rinse guidance and a repair shop are better options there.&lt;/li&gt;
&lt;li&gt;Don't use a hair dryer, compressed air or a cotton swab in the grille. Heat and air pressure can damage the speaker or the seals.&lt;/li&gt;
&lt;li&gt;If sound is still muffled after the phone has dried for a day, the speaker may be damaged.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Apple's own advice for a wet iPhone is to tap it gently, speaker side down, against your hand and let it dry. A tone is an extra step on top of that, not a replacement.&lt;/p&gt;

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

&lt;p&gt;I put this into a free &lt;a href="https://speakercleaningtool.com/water-eject" rel="noopener noreferrer"&gt;water eject tool&lt;/a&gt; that runs in the browser and never uses the microphone. Hold the phone speaker-down over a cloth, set the volume to around 75% (lower if it distorts), and run it two or three times.&lt;/p&gt;

&lt;p&gt;I'm curious whether others have found better ways to handle Web Audio on iOS, especially for people on iOS 16 and earlier. Let me know in the comments.&lt;/p&gt;

&lt;p&gt;— Nayeem, software developer&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>webaudio</category>
      <category>mobile</category>
    </item>
    <item>
      <title>Engineering a Zero-Dependency, Privacy-First Audio Widget in Under 30KB</title>
      <dc:creator>nayeem</dc:creator>
      <pubDate>Mon, 19 Oct 2026 00:30:00 +0000</pubDate>
      <link>https://dev.to/nayeem_software/engineering-a-zero-dependency-privacy-first-audio-widget-in-under-30kb-10ec</link>
      <guid>https://dev.to/nayeem_software/engineering-a-zero-dependency-privacy-first-audio-widget-in-under-30kb-10ec</guid>
      <description>&lt;p&gt;Embeds are one of the easiest ways to make someone else's page slow. A typical "free widget" brings a framework, a font, a tracking script and a cookie banner problem. I wanted to build one that a blogger could drop into a post without thinking about any of that.&lt;/p&gt;

&lt;p&gt;The widget plays a 165 Hz pulse for clearing water from phone speakers. The goals were: no cookies, no tracking, no external requests, and under 30 KB.&lt;/p&gt;

&lt;p&gt;Generate the sound, don't download it&lt;br&gt;
An MP3 of a 60-second tone is several hundred KB. Generating it with Web Audio costs a few lines:&lt;/p&gt;

&lt;p&gt;let ctx, osc, gain;&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;function&lt;/span&gt; &lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;??=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AudioContext&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;osc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OscillatorNode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;frequency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;165&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;gain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;GainNode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;gain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;osc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;gain&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;osc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="c1"&gt;// pulse: 0.8s on, 0.2s off&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;let&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ctx&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="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;ctx&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="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;t&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="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;gain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setTargetAtTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.01&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;gain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setTargetAtTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.01&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;gain&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;gain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setTargetAtTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctx&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="mf"&gt;0.02&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;osc&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&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.1&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;No network request, no codec, and the audio is identical on every device.&lt;/p&gt;

&lt;p&gt;Rules I followed&lt;br&gt;
Nothing runs until a tap. The AudioContext is created in the click handler. That's also what browsers require for audio anyway.&lt;br&gt;
The animation only runs while sound plays. The waveform's requestAnimationFrame loop stops on Stop, so an idle widget uses no CPU.&lt;br&gt;
System fonts only. A web font alone can be bigger than the whole widget.&lt;br&gt;
No storage, no cookies, no analytics inside the iframe. Nothing to disclose in the host's privacy policy.&lt;br&gt;
Theme via query string. ?theme=light or ?theme=dark. No config script on the host page.&lt;/p&gt;

&lt;p&gt;The embed code&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;iframe&lt;/span&gt;
  &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://speakercleaningtool.com/embed/water-eject?theme=dark"&lt;/span&gt;
  &lt;span class="na"&gt;width=&lt;/span&gt;&lt;span class="s"&gt;"360"&lt;/span&gt; &lt;span class="na"&gt;height=&lt;/span&gt;&lt;span class="s"&gt;"420"&lt;/span&gt;
  &lt;span class="na"&gt;loading=&lt;/span&gt;&lt;span class="s"&gt;"lazy"&lt;/span&gt;
  &lt;span class="na"&gt;title=&lt;/span&gt;&lt;span class="s"&gt;"Water Eject speaker cleaner"&lt;/span&gt;
  &lt;span class="na"&gt;style=&lt;/span&gt;&lt;span class="s"&gt;"border:0;border-radius:12px;max-width:100%"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/iframe&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;loading="lazy" means the iframe isn't fetched until the reader scrolls near it, so it doesn't affect the host page's first load. Always set width and height to avoid layout shift.&lt;/p&gt;

&lt;p&gt;SEO detail I learned the hard way&lt;br&gt;
My first version let the bare widget URL get indexed, and SEO audits flagged it as a thin page with no heading. The fix was a noindex, follow robots meta plus a canonical pointing at the full tool page. Don't block it in robots.txt, though. If crawlers can't fetch the page, they never see the noindex.&lt;/p&gt;

&lt;p&gt;Use it or copy the approach&lt;br&gt;
The widget and setup steps for WordPress, Webflow, Ghost and plain HTML are on the embed page(opens in a new tab). It's free to use. If you're building your own embed, I hope the checklist above saves you a few audit warnings.&lt;/p&gt;

&lt;p&gt;— Nayeem, Software Developer&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>performance</category>
      <category>architecture</category>
      <category>html</category>
    </item>
    <item>
      <title>Pushing Small Speakers to the Limit: Building a Multi-Harmonic Web Audio DSP Chain in Vanilla JavaScript</title>
      <dc:creator>nayeem</dc:creator>
      <pubDate>Sun, 11 Oct 2026 10:24:12 +0000</pubDate>
      <link>https://dev.to/nayeem_software/pushing-small-speakers-to-the-limit-building-a-multi-harmonic-web-audio-dsp-chain-in-vanilla-d7e</link>
      <guid>https://dev.to/nayeem_software/pushing-small-speakers-to-the-limit-building-a-multi-harmonic-web-audio-dsp-chain-in-vanilla-d7e</guid>
      <description>&lt;p&gt;I spent a few weekends trying to answer a simple question: how loud and clear can a browser make a tiny phone speaker sound, without any library and without distorting?&lt;/p&gt;

&lt;p&gt;The honest answer surprised me. Turning the gain up does almost nothing useful. What works is shaping the signal so the speaker's energy goes where it can actually move air.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a pure sine wave is a bad fit for a phone speaker
&lt;/h2&gt;

&lt;p&gt;A phone speaker is roughly 1 cm across. Below a few hundred hertz it barely moves air, so a pure 165 Hz sine spends most of its energy where the driver can't reproduce it. You hear a weak hum, and pushing the gain only clips the output.&lt;/p&gt;

&lt;p&gt;Harmonics help. If I play 165 Hz plus quieter copies at 2×, 3× and 4× (330, 495, 660 Hz), the ear still hears the 165 Hz pitch (the "missing fundamental" effect), and the speaker gets frequencies it can actually play.&lt;/p&gt;

&lt;p&gt;const ctx = new AudioContext();&lt;br&gt;
const out = ctx.createGain();&lt;/p&gt;

&lt;p&gt;const layers = [&lt;br&gt;
  { mult: 1, gain: 1.0 },&lt;br&gt;
  { mult: 2, gain: 0.55 },&lt;br&gt;
  { mult: 3, gain: 0.3 },&lt;br&gt;
  { mult: 4, gain: 0.16 },&lt;br&gt;
];&lt;/p&gt;

&lt;p&gt;function startTone(freq) {&lt;br&gt;
  return layers.map(({ mult, gain }) =&amp;gt; {&lt;br&gt;
    const osc = ctx.createOscillator();&lt;br&gt;
    const g = ctx.createGain();&lt;br&gt;
    osc.frequency.value = freq * mult;&lt;br&gt;
    g.gain.value = gain;&lt;br&gt;
    osc.connect(g).connect(out);&lt;br&gt;
    osc.start();&lt;br&gt;
    return osc;&lt;br&gt;
  });&lt;br&gt;
}&lt;/p&gt;

&lt;h2&gt;
  
  
  The chain
&lt;/h2&gt;

&lt;p&gt;Here is the order I ended up with. Each step fixes a problem the step before it creates.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Pre-gain — drive the signal hotter on purpose.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;WaveShaper (tanh curve) — soft-clip the peaks so they round off instead of cracking.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Peaking biquad at 2.8 kHz (+7 dB) — small speakers sound clearest around there.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;DynamicsCompressor — evens out the loud and quiet parts.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Make-up gain — gets back the level the compressor took.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Ceiling limiter (about -0.2 dB) — a hard stop so nothing goes over full scale.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;function softClipCurve(amount = 2.6, n = 2048) {&lt;br&gt;
  const curve = new Float32Array(n);&lt;br&gt;
  for (let i = 0; i &amp;lt; n; i++) {&lt;br&gt;
    const x = (i / (n - 1)) * 2 - 1;&lt;br&gt;
    curve[i] = Math.tanh(amount * x) / Math.tanh(amount);&lt;br&gt;
  }&lt;br&gt;
  return curve;&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;const pre = new GainNode(ctx, { gain: 2.6 });&lt;br&gt;
const shaper = new WaveShaperNode(ctx, { curve: softClipCurve(), oversample: "4x" });&lt;br&gt;
const presence = new BiquadFilterNode(ctx, { type: "peaking", frequency: 2800, Q: 1, gain: 7 });&lt;br&gt;
const comp = new DynamicsCompressorNode(ctx, { threshold: -6, ratio: 12, attack: 0.003, release: 0.1 });&lt;br&gt;
const makeup = new GainNode(ctx, { gain: 1.9 });&lt;br&gt;
const ceiling = new DynamicsCompressorNode(ctx, { threshold: -0.2, ratio: 20, attack: 0.001, release: 0.05 });&lt;/p&gt;

&lt;p&gt;out.connect(pre).connect(shaper).connect(presence)&lt;br&gt;
   .connect(comp).connect(makeup).connect(ceiling)&lt;br&gt;
   .connect(ctx.destination);&lt;/p&gt;

&lt;p&gt;oversample: "4x" matters. Without it, the tanh curve adds aliasing that sounds like a gritty buzz.&lt;/p&gt;

&lt;h2&gt;
  
  
  Loudness compensation for low notes
&lt;/h2&gt;

&lt;p&gt;Our ears are less sensitive to low frequencies (the equal-loudness, or Fletcher–Munson, curves). So I add a small boost that grows as the frequency drops:&lt;/p&gt;

&lt;p&gt;function loudnessComp(freq) {&lt;br&gt;
  if (freq &amp;lt;= 45) return 1.5;&lt;br&gt;
  if (freq &amp;lt;= 70) return 1.38;&lt;br&gt;
  if (freq &amp;lt;= 150) return 1.15;&lt;br&gt;
  return 1;&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;It's a rough step function, not a calibrated curve, and I'd rather say that up front. It just makes a 40 Hz test tone feel about as loud as a 400 Hz one on a laptop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things that bit me
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Autoplay rules. Create the AudioContext inside a click handler, or browsers will keep it suspended.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Clicks at start and stop. Ramp the gain over about 20 ms with setTargetAtTime instead of jumping to zero.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Being honest about limits. This chain makes a speaker sound fuller. It can't fix a torn cone or a blown coil, and I don't claim it can.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Where this lives
&lt;/h2&gt;

&lt;p&gt;I use this chain in a small free tool I built, the &lt;a href="https://speakercleaningtool.com/" rel="noopener noreferrer"&gt;Speaker Cleaner&lt;/a&gt;, which plays low tones and sweeps to shake dust and water out of phone speakers. Everything runs in the browser and nothing is recorded, so you can open DevTools and look at the node graph yourself.&lt;/p&gt;

&lt;p&gt;If you've built something similar, I'd like to hear how you handled limiting. I'm still not sure a compressor is the best "brickwall" in Web Audio.&lt;/p&gt;

&lt;p&gt;— Nayeem, Software Developer&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>web</category>
      <category>frontend</category>
    </item>
  </channel>
</rss>
