DEV Community

jack
jack

Posted on

How I Built a Browser Audio Test That Refuses to Guess

A browser can generate a mathematically correct left-channel signal.

It cannot tell me whether the speaker on my left actually played it.

That distinction sounds obvious, but it changed the architecture of a browser audio tool I was building. A straightforward implementation can create an oscillator, pan it left or right, and display a reassuring success state. The signal graph is correct, so the test looks complete.

Now consider a pair of speakers connected the wrong way around.

The browser would still report success. The user would hear the “left” signal from the physical speaker on the right. A loose cable, an operating-system balance setting, a Bluetooth profile change, or a damaged driver would create similar gaps between the digital graph and reality.

The correct engineering response was not to add a smarter-looking success badge. It was to stop claiming that the browser knew more than it did.

This is the model I ended up using while building Online Sound Test: generate a controlled signal, ask the listener what physically happened, and make any diagnosis traceable to that observation.

Where browser observability ends

A simplified output path looks like this:

OscillatorNode
    ↓
GainNode (signal envelope)
    ↓
StereoPannerNode
    ↓
GainNode (master level)
    ↓
AudioContext.destination
    ↓
Browser and operating-system mixer
    ↓
Driver → DAC → amplifier → cable → speaker
    ↓
What the listener actually hears
Enter fullscreen mode Exit fullscreen mode

The Web Audio specification defines AudioContext.destination as the final destination for rendered audio. In the normal case, it represents the audio hardware endpoint. That does not give the page a return channel from the room. The page knows what it rendered, not what happened after the signal left its observable graph.

This creates a useful boundary:

The browser can know The browser cannot confirm by itself
Oscillator frequency and waveform Calibrated physical loudness
Requested digital channel Which physical speaker produced sound
Gain values inside the graph Whether a cable or driver is damaged
AudioContext state and sample rate Whether the listener perceived distortion
Microphone samples, after permission Whether the microphone has a flat response

Even explicit output-device selection would not prove that the selected physical transducer worked correctly. Routing intent and acoustic outcome are different facts.

Generate a signal that is controlled and easy to stop

The signal generator itself is small. The following is a shortened version of the pattern used in the project:

type Channel = "left" | "stereo" | "right";

const DEFAULT_GAIN_DB = -18;
const MAX_GAIN_DB = -6;

function dbToGain(db: number) {
  const bounded = Math.min(MAX_GAIN_DB, Math.max(-60, db));
  return Math.pow(10, bounded / 20);
}

function channelPan(channel: Channel) {
  if (channel === "left") return -1;
  if (channel === "right") return 1;
  return 0;
}

class TestSignal {
  private context: AudioContext | null = null;
  private master: GainNode | null = null;
  private active = new Set<AudioScheduledSourceNode>();

  private async ready() {
    if (!this.context) {
      this.context = new AudioContext();
      this.master = this.context.createGain();
      this.master.gain.value = dbToGain(DEFAULT_GAIN_DB);
      this.master.connect(this.context.destination);
    }

    if (this.context.state === "suspended") {
      await this.context.resume();
    }

    return { context: this.context, master: this.master! };
  }

  async play(channel: Channel, frequency = 1000, duration = 1.2) {
    this.stop();

    const { context, master } = await this.ready();
    const safeDuration = Math.min(12, Math.max(0.08, duration));
    const oscillator = context.createOscillator();
    const envelope = context.createGain();
    const panner = context.createStereoPanner();
    const now = context.currentTime;

    oscillator.type = "sine";
    oscillator.frequency.value = Math.min(20_000, Math.max(20, frequency));
    panner.pan.value = channelPan(channel);

    envelope.gain.setValueAtTime(0.0001, now);
    envelope.gain.exponentialRampToValueAtTime(1, now + 0.035);
    envelope.gain.setValueAtTime(
      1,
      now + Math.max(0.045, safeDuration - 0.06),
    );
    envelope.gain.exponentialRampToValueAtTime(0.0001, now + safeDuration);

    oscillator.connect(envelope).connect(panner).connect(master);
    this.active.add(oscillator);

    oscillator.addEventListener(
      "ended",
      () => this.active.delete(oscillator),
      { once: true },
    );

    oscillator.start(now);
    oscillator.stop(now + safeDuration + 0.01);
  }

  stop() {
    for (const source of this.active) {
      try {
        source.stop();
      } catch {
        // It may already have reached its scheduled stop time.
      }
    }
    this.active.clear();
  }
}
Enter fullscreen mode Exit fullscreen mode

There are a few deliberate choices here.

First, playback begins from an explicit user action. Browsers apply autoplay rules to Web Audio, so a context may need to be resumed from a click or another user gesture. This is also the correct product behavior: a diagnostic page should never surprise someone with a test tone.

Second, the app starts at a reduced level and caps its own gain below full scale. This does not control the operating system, amplifier, or hardware volume, so the UI must still tell the listener to begin low. It simply avoids making the application itself unnecessarily aggressive.

Third, every signal has a short amplitude envelope. Jumping instantly from zero to a non-zero sample can produce an audible click. A brief fade-in and fade-out makes a short diagnostic tone much less abrupt.

Finally, every active source can be stopped. Scheduled Web Audio sources expose start(), stop(), and an ended event, which makes explicit lifecycle management possible.

I also stop playback when the page is hidden or unloaded:

const stopWhenHidden = () => {
  if (document.hidden) engine.stop();
};

document.addEventListener("visibilitychange", stopWhenHidden);
window.addEventListener("pagehide", () => engine.stop());
Enter fullscreen mode Exit fullscreen mode

A background tab that continues emitting a tone is not just irritating. It makes the state of the test ambiguous.

A requested channel is not an observed result

StereoPannerNode.pan = -1 means fully left in the stereo image; 1 means fully right. It is tempting to turn that parameter directly into a result:

// Wrong abstraction
return { leftSpeaker: "working" };
Enter fullscreen mode Exit fullscreen mode

But the graph only supports a narrower statement:

return { requestedChannel: "left", signalCompleted: true };
Enter fullscreen mode Exit fullscreen mode

The physical result has to come from the listener:

interface Observation {
  leftHeard?: boolean;
  rightHeard?: boolean;
  correctSides?: boolean;
  balanced?: boolean;
  distorted?: boolean;
  bluetooth?: boolean;
}
Enter fullscreen mode Exit fullscreen mode

The optional properties matter. There are three states, not two:

  • true: the listener tested and confirmed it;
  • false: the listener tested and rejected it;
  • undefined: there is no observation.

An unanswered question must not silently become a pass. It must not silently become a failure either. Treating missing evidence as evidence is a subtle way to make a diagnostic system sound more certain than it is.

Diagnosis should show its evidence

Once observations are explicit, the diagnosis layer can remain simple and inspectable.

function diagnose(observation: Observation) {
  const causes: string[] = [];

  if (
    observation.leftHeard === false &&
    observation.rightHeard === false
  ) {
    causes.push("output-routing");
  }

  if (
    observation.leftHeard === false ||
    observation.rightHeard === false
  ) {
    causes.push("channel-connection");
  }

  if (observation.balanced === false) {
    causes.push("balance-setting");
  }

  if (observation.distorted === true) {
    causes.push("processing-overload");
  }

  return [...new Set(causes)].slice(0, 3);
}
Enter fullscreen mode Exit fullscreen mode

The production rules attach three things to every result:

  1. a confidence label such as likely, possible, or check;
  2. a sentence explaining which observation caused the result to appear;
  3. a repair path followed by the same controlled retest.

For example, if neither channel was confirmed, checking output routing is a useful first step. If only one channel was confirmed, a connection or balance problem becomes more relevant. Neither result proves that a particular component failed.

This is intentionally less impressive than “AI detected your broken speaker.” It is also much easier to explain, test, and correct.

What automated tests can—and cannot—prove

The observability boundary also applies to CI.

Unit tests can verify the deterministic parts:

expect(channelPan("left")).toBe(-1);
expect(channelPan("stereo")).toBe(0);
expect(channelPan("right")).toBe(1);

expect(dbToGain(-6)).toBeLessThan(1);
Enter fullscreen mode Exit fullscreen mode

They can verify that diagnosis rules rank output routing first when neither side was heard, preserve the difference between false and missing data, and avoid duplicate causes.

Browser tests can verify that:

  • playback requires an intentional action;
  • the Stop control changes state correctly;
  • a signal cannot be confirmed before it completes;
  • hiding the document stops active sources;
  • microphone permission granted, denied, and pending paths have usable UI;
  • a fake microphone stream is released after the test.

What CI cannot prove is that the developer's right-hand speaker emitted the right-channel tone at a particular sound pressure level. A fake microphone is useful for permission and lifecycle tests. It is not evidence about physical acoustics.

That final check belongs in a real-hardware test matrix: different browsers, operating systems, built-in speakers, wired headphones, Bluetooth devices, and an actual listener.

Honest limitations make the tool more useful

It is easy to treat limitations as legal copy that belongs at the bottom of a page. In diagnostic software, limitations are part of the data model.

Once I separated generated facts from observed facts, several design decisions became clearer:

  • A completed oscillator is not a passed speaker test.
  • “Not sure” is data absence, not success.
  • A likely cause needs visible evidence.
  • Repair guidance should end with the same retest.
  • Local microphone analysis can improve privacy, but it does not create calibrated hardware.
  • Automated browser coverage does not replace real-device listening.

You can try the resulting flow in the live browser sound test or read the more formal test methodology. I also extracted repeatable channel, frequency, and routing checks into an open-source Browser Audio Test Kit for developers and QA work.

I am curious how other teams draw this boundary. If your web application crosses from a deterministic browser API into hardware or human perception, which facts do you record automatically, and which ones do you ask the user to confirm?


References

_Editorial note: I used an AI assistant to help structure and edit this article. The implementation decisions, code behavior, and conclusions were checked against the working project and the linked platform documentation.

Top comments (0)