# How to Build a Fair A/B Audio Preview for AI Processing

> Source: <https://dev.to/yidao_713c5eeea4f16821823/how-to-build-a-fair-ab-audio-preview-for-ai-processing-52ne>
> Published: 2026-08-24 09:32:34+00:00

Two audio players do not make a fair before-and-after test.

If the second player restarts from zero or takes half a second to load, the user is no longer comparing two versions of the same moment. They are comparing two memories.

That is a weak way to evaluate any audio effect. It is especially weak for AI processing.

A denoiser can remove a fan while softening consonants. A de-reverb model can reduce the room tail while making the voice sound less natural. The output may be cleaner without being better.

The preview therefore has one job: let the listener switch quickly enough to hear both the improvement and the damage.

The rule I use is deliberately boring. Both versions should contain the same edit and play from the same position. Switching should not restart playback or create a pause. The interface should not hint that one version is supposed to win.

Two independent `<audio>`

elements fail surprisingly quickly. Each owns its playback state, buffering behavior, clock, and seek operation. The user ends up finding the same position twice and comparing one sound with a memory of another.

A better interface has one transport and one version control:

```
[ Play ]  [ Original | Processed ]  00:18 ━━━━━━━ 00:42
```

The transport decides *where* playback happens. The segmented control decides *which signal* is audible.

For a short preview, I decode both files into `AudioBuffer`

s, start them at the same `AudioContext`

time and offset, and route each through its own `GainNode`

. Both sources run; only one gain is open.

`decodeAudioData()`

decodes complete file data and resamples it to the context's sample rate. The decoded buffers can then share the same audio clock. See the [MDN documentation](https://developer.mozilla.org/en-US/docs/Web/API/BaseAudioContext/decodeAudioData) for format and loading details.

The core is small:

``` js
const context = new AudioContext();
const originalGain = context.createGain();
const processedGain = context.createGain();

originalGain.connect(context.destination);
processedGain.connect(context.destination);

async function loadBuffer(url) {
  const response = await fetch(url);
  return context.decodeAudioData(await response.arrayBuffer());
}

const buffers = {
  original: await loadBuffer("/audio/original.wav"),
  processed: await loadBuffer("/audio/processed.wav"),
};

let sources = [];

function startPair(fromSeconds = 0) {
  const when = context.currentTime + 0.03;
  const duration = Math.min(
    buffers.original.duration,
    buffers.processed.duration,
  );

  stopPair();
  sources = Object.entries(buffers).map(([name, buffer]) => {
    const source = context.createBufferSource();
    source.buffer = buffer;
    source.connect(name === "original" ? originalGain : processedGain);
    source.start(when, fromSeconds, duration - fromSeconds);
    return source;
  });

  setAudibleVersion(activeVersion, when, 0);
}

function stopPair() {
  for (const source of sources) source.stop();
  sources = [];
}
```

Call `context.resume()`

and `startPair()`

from a click or tap handler. Track the elapsed offset in your transport; pause and seek should stop the pair and create new source nodes at that offset. An `AudioBufferSourceNode`

is intentionally single-use, while its decoded `AudioBuffer`

can be reused. [MDN explains the lifecycle here](https://developer.mozilla.org/en-US/docs/Web/API/AudioBufferSourceNode).

An instant gain jump can click when it lands away from a zero crossing. A long crossfade hides the click but makes the comparison less precise because the listener hears a blend. For speech, I start with a ramp of a few milliseconds and tune it on real material.

``` js
const versionGains = {
  original: originalGain,
  processed: processedGain,
};

let activeVersion = "original";

function setAudibleVersion(version, now = context.currentTime, ramp = 0.012) {
  activeVersion = version;

  for (const [name, node] of Object.entries(versionGains)) {
    const target = name === version ? 1 : 0;

    node.gain.cancelScheduledValues(now);

    if (ramp === 0) {
      node.gain.setValueAtTime(target, now);
      continue;
    }

    node.gain.setValueAtTime(node.gain.value, now);
    node.gain.linearRampToValueAtTime(target, now + ramp);
  }
}

document.querySelector("#show-original").addEventListener("click", () => {
  setAudibleVersion("original");
});

document.querySelector("#show-processed").addEventListener("click", () => {
  setAudibleVersion("processed");
});
```

The 12 ms value is a starting point, not a standard. Listen for clicks, comb filtering during the overlap, and any sense that the button responds late. If the files are not sample-aligned, even a short overlap can sound strange.

Starting both buffers at the same Web Audio time only works if the files contain corresponding samples at corresponding positions.

An AI pipeline may introduce delay through frame padding, look-ahead context, silence trimming, resampling, or codec priming.

If the processed voice begins 80 ms later, the browser will faithfully play the wrong moments together.

Fix alignment before the files reach the comparison UI. Preserve the original timeline through processing, or estimate the delay against a reference and compensate by trimming or padding. Keep the preview lengths identical; do not quietly loop one version after the other ends.

Test this with a transient: switch repeatedly and confirm that its position does not move.

Most users do not need to compare an entire 40-minute recording. They need to inspect the four seconds where a keyboard overlaps a sentence or traffic passes behind a voice.

A shared timeline and a short loop region are more useful than two large players. Keep the A/B controls keyboard-accessible, show which version is audible, and do not stop the loop when the listener switches.

The waveform is useful for navigation, but it is not evidence of quality. A smoother waveform does not prove that speech sounds more natural. Avoid using visual cleanup as a substitute for listening.

This also keeps the buffer-based approach within its natural limits. Decoded PCM is much larger than MP3 or AAC, and the design holds two versions in memory. `decodeAudioData()`

expects a complete file, too. For a long recording, generate a bounded preview, decode chunks, or move to a more deliberate streaming strategy.

On the browser side, expect autoplay blocking, mobile interruptions, codec failures, and aggressive toggling. Resume the `AudioContext`

from a user gesture and make decode failure an ordinary UI state. MDN's [autoplay guide](https://developer.mozilla.org/en-US/docs/Web/Media/Guides/Autoplay) is worth keeping nearby.

Do not switch between two video players. Keep one video element on screen and treat its timeline as the master clock. Mute its embedded audio, then audition aligned original and processed audio tracks through the A/B graph.

The video should drive playback, pause, seek, and rate-change events. After a seek, stop both audio sources and recreate them from `video.currentTime`

. If playback speed changes, apply the same `playbackRate`

to both new sources.

There is an important limit: `HTMLMediaElement.currentTime`

and `AudioContext.currentTime`

are different clocks. Listening for events does not guarantee sample-accurate sync across browsers. Check drift during playback and rebuild both audio sources at `video.currentTime`

when it exceeds your tolerance. For short speech previews, this is often sufficient. Frame-accurate editing needs a shared media timeline built with a more controlled pipeline such as MSE or WebCodecs.

The processing backend must still preserve duration and compensate for model and codec delay. A perfect front-end clock cannot align files whose content starts at different offsets.

An AI tool naturally wants to say "enhancement complete" and move on to download. But the processed version may have less noise and worse speech. Both can be true.

I prefer plain labels such as `Original`

and `Processed`

, with neither option visually dominant. The preview is not decoration after a successful model run. It is how the user catches a bad run before committing to it.

While working on [CleanAudio](https://www.cleanaudio.io/), this became a useful product principle for me: when AI changes a user's media, confidence should come from comparison, not from the success message.

Before shipping, I listen for the unglamorous failures: a transient that moves when I switch, a click at the boundary, drift after seeking a video, or a phone interruption that leaves the controls lying about playback.

The code is the smaller part. The harder part is resisting all the little ways a comparison can be made to favor the result we want users to choose.

*Disclosure: I am involved in building CleanAudio, an AI audio and video noise-removal tool. The implementation pattern and opinions in this article are presented as general product and engineering guidance.*
