I set out to build a very small browser tool: press a button, play a short low-frequency sweep, stop after 30 seconds.

The first prototype was maybe twenty lines of Web Audio code. The version I shipped is still small, but the hard part turned out not to be generating a tone. The hard part was making the tone behave like a decent citizen on a phone: start only when someone asks for it, avoid an abrupt click, stop when the tab disappears, and leave nothing running behind the UI.

That made it a useful little case study in the difference between “the API works” and “this feels safe to put in front of people.”

The audio graph I actually needed

The sound path is deliberately boring:

OscillatorNode → GainNode → AudioDestinationNode

Enter fullscreen mode Exit fullscreen mode

An oscillator gives me a deterministic sine wave. The gain node is there to shape the beginning and end of playback. I could have connected the oscillator straight to context.destination, but that is how I got the first unpleasant lesson: an oscillator that starts or stops at a non-zero point in the waveform can produce a click.

This is the core setup, stripped down to the pieces that matter:

const context = new AudioContext();
const oscillator = context.createOscillator();
const gain = context.createGain();

await context.resume();

oscillator.type = "sine";
oscillator.connect(gain);
gain.connect(context.destination);

Enter fullscreen mode Exit fullscreen mode

There is no microphone input, file picker, upload pipeline, or audio recording in this path. The browser generates the tone on the device and sends it to the device's normal audio destination.

That constraint was useful. It kept the product small, but it also removed a surprising number of privacy and failure modes from the design.

Audio begins with a user gesture

Autoplay rules are not a corner case for a tool like this; they are the normal case. Creating an audio context on page load and hoping it will make sound later is an invitation to confusing behavior, especially on mobile browsers.

I create and resume the context inside the button handler. I also keep the WebKit constructor fallback because the experience is intended for phones, not only a desktop development machine.

type WebkitWindow = Window &
  typeof globalThis & {
    webkitAudioContext?: typeof AudioContext;
  };

const browserWindow = window as WebkitWindow;
const AudioContextConstructor =
  browserWindow.AudioContext ?? browserWindow.webkitAudioContext;

if (!AudioContextConstructor) {
  setStatus("error");
  return;
}

const context = new AudioContextConstructor();
await context.resume();

Enter fullscreen mode Exit fullscreen mode

The button is doing more than starting sound. It is the point where the browser receives an explicit intent to play audio. Treating that as part of the interface, rather than an implementation detail, made the failure state much easier to explain.

The click that made the first version feel broken

The initial version used oscillator.start() and oscillator.stop() with no envelope. It technically worked. It also sounded a little like a fault when it began or ended on some speakers.

The fix was a small gain envelope. I ramp from silence to a modest gain over 250 ms, hold it during the cycle, then return to silence before the scheduled stop. The live tool runs for 30 seconds.

const CLEANING_CYCLE_MS = 30_000;
const startedAt = context.currentTime;

gain.gain.setValueAtTime(0, startedAt);
gain.gain.linearRampToValueAtTime(0.12, startedAt + 0.25);
gain.gain.setValueAtTime(
  0.12,
  startedAt + (CLEANING_CYCLE_MS - 350) / 1_000,
);
gain.gain.linearRampToValueAtTime(
  0,
  startedAt + CLEANING_CYCLE_MS / 1_000,
);

oscillator.start(startedAt);
oscillator.stop(startedAt + CLEANING_CYCLE_MS / 1_000 + 0.02);

Enter fullscreen mode Exit fullscreen mode

The exact gain value is not especially interesting. The envelope is. It gave the control a predictable beginning and end, and it made the stop button feel intentional instead of abrupt.

If I were building a music tool, I would probably expose more of this as a parameter. For a single-purpose control, a fixed, conservative envelope is a better trade-off.

I used a range, not a universal frequency

I did not want to present one number as the answer for every phone speaker. The tool moves smoothly between 150 Hz and 190 Hz, then back again, with a four-second triangle pattern.

Here is the pure function behind that behavior:

const SWEEP_MIN_HZ = 150;
const SWEEP_MAX_HZ = 190;
const SWEEP_PERIOD_MS = 4_000;

function getSweepFrequency(elapsedMs: number) {
  const phase =
    (((elapsedMs % SWEEP_PERIOD_MS) + SWEEP_PERIOD_MS) % SWEEP_PERIOD_MS) /
    SWEEP_PERIOD_MS;
  const triangle = phase <= 0.5 ? phase * 2 : (1 - phase) * 2;

  return SWEEP_MIN_HZ + (SWEEP_MAX_HZ - SWEEP_MIN_HZ) * triangle;
}

Enter fullscreen mode Exit fullscreen mode

The function is easy to test because it has no dependency on React or the audio context. In the component, I call it from requestAnimationFrame, update the frequency at the context's current time, and use the same snapshot to drive the visible countdown and frequency readout.

That last part mattered more than I expected. A fixed duration without feedback feels arbitrary. A running timer gives someone a clear answer to “is it still doing anything?” and makes stopping early a normal action rather than a failure.

Stopping is part of the feature

Starting an oscillator is easy. Cleaning up after one requires a little more discipline.

I keep refs to the audio context, oscillator, gain node, and animation-frame ID. When a cycle is stopped early, I cancel the frame, ramp gain down briefly, schedule a stop, disconnect the nodes, and close the context. The code is defensive because an oscillator may already have reached its scheduled stop by the time cleanup runs.

const now = context.currentTime;
gain.gain.cancelScheduledValues(now);
gain.gain.setValueAtTime(gain.gain.value, now);
gain.gain.linearRampToValueAtTime(0, now + 0.08);

oscillator.stop(now + 0.09);

window.setTimeout(() => {
  oscillator.disconnect();
  gain.disconnect();
  void context.close();
}, 120);

Enter fullscreen mode Exit fullscreen mode

The other lifecycle rule is simple: if the document becomes hidden while sound is playing, stop the cycle.

useEffect(() => {
  const stopWhenHidden = () => {
    if (document.hidden && isRunningRef.current) {
      stopCycle();
    }
  };

  document.addEventListener("visibilitychange", stopWhenHidden);
  return () => {
    document.removeEventListener("visibilitychange", stopWhenHidden);
    releaseAudio(false);
  };
}, [releaseAudio, stopCycle]);

Enter fullscreen mode Exit fullscreen mode

This avoids a weird class of “I switched apps and my phone was still playing a tone” reports. It also gives unmounting a clear cleanup path.

Local-only was an implementation choice

“Runs locally” can sound like a slogan, so I tried to make it concrete in the interface.

The audio feature does not request microphone permission, inspect files, or send audio anywhere. The UI shows a local-audio label, a 30-second countdown, the current frequency, and a live status message for screen readers. Those pieces are not decoration. They make the state of an otherwise invisible process observable.

The site has optional analytics behind a consent choice, but that is separate from the audio path. Someone can use the tool without granting analytics consent; the sound generation itself still happens in the browser.

A boundary worth stating plainly

I built this around a narrow claim: moving a speaker diaphragm with a low tone may help shift a small droplet sitting near an external grille. It is not a repair method. It cannot dry internal components, reverse liquid damage, or replace a device maker's guidance.

That boundary belongs in the product, not in a footnote. If the phone has a liquid warning, charging trouble, heat, severe distortion, or broader hardware symptoms, sound is the wrong next step. I keep the safety notes I publish alongside the tool separate from the implementation because the two jobs are different: one explains the engineering, the other helps people decide when not to use it.

What I would like to hear from other Web Audio builders

This was a small project, but it made me pay more attention to the lifecycle around a browser API than the API itself. The oscillator was the easy bit. The useful work was deciding how it starts, how it stops, and what the person using it can understand while it is running.

Try the live demo: Speaker Cleaner

If you have shipped Web Audio on mobile, I would be curious how you handle cleanup when a page is backgrounded. Do you stop immediately, suspend the context, or take a different approach?


Pre-publish notes (do not paste into the article)

  1. Upload the cover image and replace its automatically inserted alt text with the supplied description.
  2. Keep the two Speaker Cleaner links exactly as written; do not add a homepage link in the opening or code sections.
  3. In DEV preview, confirm the TypeScript code fences display correctly and that the final question remains the last paragraph.
  4. Publish manually after previewing the mobile layout. Record the resulting DEV URL in the backlink ledger as live; wait for separate evidence before classifying it as indexed, Google-discovered, or referral traffic.