DEV Community

athena886
athena886

Posted on

How to Build a Browser-Based Ringtone Editor with the Web Audio API

You do not need a server to build a useful audio trimmer. Modern browsers can decode an audio file, let the user select a time range, and generate a downloadable clip without uploading the source file anywhere.

This tutorial walks through the core of a small ringtone editor using the Web Audio API and a zero-dependency JavaScript module called ringtone-slicer.

The finished flow is simple:

  1. Read a local file with File.arrayBuffer().
  2. Decode it into an AudioBuffer.
  3. Convert start and end times to sample positions.
  4. Copy the selected samples and apply short fades.
  5. Encode the clip as 16-bit PCM WAV.
  6. preview or download the generated Blob.

Everything stays in the browser tab.

1. Decode the user's audio file

Start with a file input:

<input id="audio-file" type="file" accept="audio/*" />
Enter fullscreen mode Exit fullscreen mode

Create an AudioContext, read the file, and ask the browser to decode it:

const fileInput = document.querySelector('#audio-file');
let decodedAudio;

fileInput.addEventListener('change', async () => {
  const file = fileInput.files[0];
  if (!file) return;

  const context = new AudioContext();
  const bytes = await file.arrayBuffer();
  decodedAudio = await context.decodeAudioData(bytes);

  console.log({
    duration: decodedAudio.duration,
    sampleRate: decodedAudio.sampleRate,
    channels: decodedAudio.numberOfChannels,
  });

  await context.close();
});
Enter fullscreen mode Exit fullscreen mode

decodeAudioData() returns an AudioBuffer. The buffer exposes one Float32Array per channel, with samples normally ranging from -1 to 1.

Browser codec support varies, so the UI should handle decode failures instead of promising that every possible audio format will work.

2. Convert time to sample positions

Audio editing happens at the sample level. If the user selects 12.5 to 32.5 seconds, convert those values using the buffer's sample rate:

const startSample = Math.floor(startSeconds * source.sampleRate);
const endSample = Math.min(
  source.length,
  Math.ceil(endSeconds * source.sampleRate),
);
Enter fullscreen mode Exit fullscreen mode

Using floor for the start and ceil for the end avoids accidentally making the requested clip shorter because of fractional samples.

Always clamp both values to the source duration and reject an empty or reversed range.

3. Copy the selected channel data

Create a new typed array for every channel. Do not return a view into the original buffer if later processing might mutate it.

function sliceAudio(source, startSeconds, endSeconds) {
  const duration = source.length / source.sampleRate;
  const start = Math.max(0, Math.min(startSeconds, duration));
  const end = Math.max(0, Math.min(endSeconds, duration));

  if (end <= start) {
    throw new RangeError('end must be greater than start');
  }

  const startSample = Math.floor(start * source.sampleRate);
  const endSample = Math.min(
    source.length,
    Math.ceil(end * source.sampleRate),
  );
  const length = endSample - startSample;
  const channelData = [];

  for (let channel = 0; channel < source.numberOfChannels; channel += 1) {
    const input = source.getChannelData(channel);
    const output = new Float32Array(length);

    for (let index = 0; index < length; index += 1) {
      output[index] = input[startSample + index];
    }

    channelData.push(output);
  }

  return {
    sampleRate: source.sampleRate,
    length,
    duration: length / source.sampleRate,
    numberOfChannels: source.numberOfChannels,
    channelData,
  };
}
Enter fullscreen mode Exit fullscreen mode

Keeping this function independent from the DOM makes it easy to test with an AudioBuffer-compatible object in Node.

4. Add short fade envelopes

A hard cut can click when the waveform is not close to zero at the boundary. A short fade-in and fade-out make ringtone clips sound cleaner.

For each output sample, calculate a gain from 0 to 1:

let gain = 1;

if (fadeInSamples > 0 && index < fadeInSamples) {
  gain = Math.min(gain, index / fadeInSamples);
}

const samplesFromEnd = length - 1 - index;

if (fadeOutSamples > 0 && samplesFromEnd < fadeOutSamples) {
  gain = Math.min(gain, samplesFromEnd / fadeOutSamples);
}

output[index] = input[startSample + index] * gain;
Enter fullscreen mode Exit fullscreen mode

Fades around 20–80 milliseconds are usually enough to soften the edges without changing the musical phrase.

5. Encode a PCM WAV

An AudioBuffer is not a downloadable audio file. For a dependency-free export, write a standard 44-byte WAV header followed by interleaved 16-bit PCM samples.

The important conversion is:

const clamped = Math.max(-1, Math.min(sample, 1));
const pcm = Math.round(clamped * (clamped < 0 ? 0x8000 : 0x7fff));
view.setInt16(offset, pcm, true);
Enter fullscreen mode Exit fullscreen mode

The final byte array can become a blob:

const wavBytes = encodeWav(clip);
const wavBlob = new Blob([wavBytes], { type: 'audio/wav' });
const objectUrl = URL.createObjectURL(wavBlob);
Enter fullscreen mode Exit fullscreen mode

Use the object URL for both preview and download:

const audio = document.querySelector('audio');
audio.src = objectUrl;

const link = document.createElement('a');
link.href = objectUrl;
link.download = 'ringtone-clip.wav';
link.click();
Enter fullscreen mode Exit fullscreen mode

Remember to call URL.revokeObjectURL() when replacing an old preview.

6. Use the extracted module

The complete slicing, fading, conversion, type declarations, tests, and standalone demo are in the open-source ringtone-slicer repository.

With the module checked out locally, the editor code becomes:

import { encodeWav, sliceAudio } from './src/index.js';

const clip = sliceAudio(decodedAudio, {
  start: 12.5,
  end: 32.5,
  fadeIn: 0.02,
  fadeOut: 0.08,
});

const blob = new Blob([encodeWav(clip)], { type: 'audio/wav' });
Enter fullscreen mode Exit fullscreen mode

You can also try the full editing workflow in this free MP3 ringtone maker. It keeps the same privacy model: the selected audio is processed locally and is not uploaded to an application server.

Important scope decisions

There are a few boundaries worth making explicit:

  • AudioContext.decodeAudioData() handles decoding only for formats supported by the user's browser.
  • Renaming a WAV file to .mp3 or .m4r does not convert its codec or container.
  • Real MP3 or AAC/M4R export needs a dedicated encoder, often with a larger download and more CPU work.
  • Long source files can consume significant memory because decoded PCM is much larger than compressed audio.
  • Copyright and permitted use remain the user's responsibility.

For a small browser tool, a correct WAV export is a much better starting point than pretending that an extension change is a format conversion.

What I would add next

The next useful improvements are waveform rendering, keyboard-accessible range controls, cancellation for expensive work, and optional compressed export loaded only when requested.

The core can stay small: decode in the browser, keep the editing logic pure, test sample boundaries, and make the privacy behavior obvious.

Top comments (0)