How Audio Trim is built
Audio Trim cuts an audio file down to a start/end range entirely in the browser; this post covers the decode/waveform/encode pipeline behind it, in particular the peak-waveform rendering and the range-based sample slicing.
Tech used
Web Audio API for decoding
The browser’s Web Audio API exposes an AudioContext with a decodeAudioData(arrayBuffer) method: hand it the raw bytes of an MP3, WAV, M4A or similar file and it returns an AudioBuffer — one Float32Array of samples per channel, normalized to -1..1, plus the sample rate and duration. Decoding happens natively in the browser’s own audio pipeline, not in JavaScript, so it is fast even for long files. Audio Trim uses this as its only decode step: drop a file, and its raw PCM samples are in hand a moment later, with no server round trip and no format-specific parser to write.
Peak downsampling for the waveform
A decoded file can easily have several million samples (a 3-minute song at 44.1kHz is over 7.9 million), but the waveform only has a few hundred to a couple thousand horizontal pixels to draw into. The standard technique — used by essentially every audio editor — is a peak waveform: divide the samples into as many buckets as there are canvas columns, and for each bucket keep only the minimum and maximum sample value. Drawing a vertical bar from that column’s min to its max, repeated across all columns, reconstructs the visual envelope of the waveform without ever touching most of the underlying samples at draw time.
export function computePeaks(samples: Float32Array, columns: number): PeakData {
const min = new Float32Array(columns);
const max = new Float32Array(columns);
const bucketSize = samples.length / columns;
for (let i = 0; i < columns; i++) {
const start = Math.floor(i * bucketSize);
const end = Math.floor((i + 1) * bucketSize);
let lo = samples[start], hi = samples[start];
for (let j = start; j < end; j++) {
if (samples[j] < lo) lo = samples[j];
if (samples[j] > hi) hi = samples[j];
}
min[i] = lo; max[i] = hi;
}
return { min, max };
}
This runs once, right after decode, against a fixed column count (800 in Audio Trim’s case) — not against the canvas’s actual on-screen pixel width. That decoupling matters: the canvas can then be resized (window resize, orientation change) without recomputing peaks from the full sample array again — the draw step just re-maps the same 800 peak pairs onto whatever width the container currently has, which is cheap regardless of file length.
Canvas rendering, no charting library
The waveform itself is a plain <canvas> with ctx.fillRect() calls: for each of the 800 peak pairs, one filled rectangle from mid - max*halfHeight to mid - min*halfHeight. No charting or waveform library is involved — a peak waveform is just bars, and a <canvas> 2D context is enough. The canvas is sized with devicePixelRatio taken into account (canvas.width = cssWidth * dpr, then ctx.setTransform(dpr, 0, 0, dpr, 0, 0)) so it stays crisp on high-DPI screens, and it redraws via a ResizeObserver on its container rather than a window resize listener, so it also responds correctly if the container’s width changes for reasons other than a viewport resize (e.g. a layout shift).
lamejs for MP3 encoding
lamejs is a pure-JavaScript port of the LAME MP3 encoder — no WebAssembly, no native binary. It exposes an Mp3Encoder that takes signed 16-bit PCM in fixed-size blocks (1152 samples, one MP3 frame) and returns encoded bytes per block, plus a final flush() for the last partial frame. Audio Trim converts its Float32 PCM to Int16 and feeds it through in that block size, exactly as its sibling tool audio-silence-remover already does — this part of the pipeline is shared logic, not something new to this tool.
Implementation & operational notes
The new part is which samples get encoded, not how encoding works. Where a silence-remover has to run signal analysis (walk every sample, decide loud/quiet, merge runs, apply padding) to decide what to keep, a trim tool only needs a start second and an end second, converted to sample indices, and a single Float32Array.subarray(start, end) — a view, not a copy — handed to the same encoder. That’s the whole “engine” difference between the two tools:
export function computeSampleRange(totalSamples, sampleRate, startSec, endSec) {
const start = Math.min(totalSamples, Math.max(0, Math.round(startSec * sampleRate)));
const end = Math.min(totalSamples, Math.max(0, Math.round(endSec * sampleRate)));
if (end <= start) throw new AppError('errZeroLengthSelection');
return { start, end };
}
Two ways to set the range, one piece of state. The waveform has two draggable handles (role="slider", positioned with setPointerCapture so a drag continues to track even if the pointer leaves the narrow handle element) and two mm:ss.ms text inputs. Both write to the same { startSec, endSec } state, so dragging updates the text and typing updates the handle position — there’s no separate “apply” step. The tricky part is the text input: it can’t be a naively controlled input bound straight to formatTimecode(startSec), because every keystroke would either need to produce a valid parse (rejecting a user mid-way through typing “01:2” before they get to “01:23”) or the field would visibly snap back on every render. The fix is a local “draft” string, free to hold any text while focused, that only gets parsed and committed to the real range on blur or Enter — reverting to the last valid value if the parse fails.
Validity is a UI concern, not just an engine one. computeSampleRange throws if the requested range is empty, but by the time a user can click Trim, that should already be unreachable: the Trim button is disabled whenever endSec - startSec isn’t comfortably positive, so the thrown error is a defense-in-depth backstop (covered by its own unit test) rather than something a user is expected to hit in normal use.
MP3 frame boundaries mean the encoded duration is approximate, not exact. Because lamejs encodes in fixed 1152-sample frames, the trimmed output’s length rounds to the nearest frame rather than landing on the exact requested sample — a few milliseconds of drift is expected and is why the tool’s own test suite checks the decoded duration of its own output (via AudioContext.decodeAudioData on the downloaded file, run back through the browser) against a tolerance, rather than asserting an exact match.
Try it / source
Try it at Trim Audio. Source on GitHub.