You load a sound into the browser, pick a start and an end, slice the samples and play the result. It works, except for a tiny tick at the very start and another at the end. On a long clip you might not notice. On a two-second reaction sound that plays over and over, the tick becomes part of the sound, and it's the part people remember.
This post explains where that click comes from and fixes it with two small ideas: move each cut point to a nearby zero crossing, then apply a very short fade at both ends. Everything works on plain Float32Arrays, so the core runs the same in the browser and in Node, which makes it easy to test.
Where the click comes from
Decoded audio is a list of numbers between -1 and 1, one per sample per channel. A sound wave moves smoothly through those values. When you cut at an arbitrary sample, the clip can start at, say, 0.6. The speaker cone was at rest a moment ago and now has to jump straight to 0.6. That instant jump is a sharp edge, and a sharp edge contains energy across the whole frequency range. You hear it as a click.
The end has the same problem in reverse: the clip stops at some non-zero value and the output drops to silence in one sample.
So the goal is simple: make the clip start and end at (or very near) zero, and make the change gradual.
Step 1: snap the cut to a zero crossing
A zero crossing is a point where the signal changes sign, so the value there is close to zero. If the cut moves a few samples to land on one, the jump mostly disappears. We only search a small window (a few milliseconds) so the cut doesn't drift audibly from where the user put it.
For stereo files there's a catch: the left and right channels cross zero at different places. If you snap each channel separately, the channels end up different lengths and slightly out of sync. The fix is to find the crossing on a mixed-down copy and cut every channel at that same sample.
Step 2: a short fade at both ends
Some sounds don't cross zero near the cut, for example a low hum or a sound that's loud right at the edge. A fade handles those. A few milliseconds is too short to hear as a fade, but long enough to round off the edge.
I use an equal-power curve (a quarter of a sine wave) rather than a straight line. A linear fade works too; the sine curve just rises a little faster at first, so the start of the sound stays punchy.
The code
Here's the whole module. It has no dependencies.
// declick.mjs: cut a clip out of decoded audio without clicks at the edges. // Works on plain Float32Arrays, so it runs the same in the browser and in Node. /** Index of the zero crossing nearest to `index`, searching up to `radius` samples each way. */ export function nearestZeroCrossing(samples, index, radius) { const last = samples.length - 1; const at = (i) => i >= 1 && i <= last && (samples[i] === 0 || Math.sign(samples[i]) !== Math.sign(samples[i - 1])); for (let d = 0; d <= radius; d++) { if (at(index - d)) return index - d; if (at(index + d)) return index + d; } return index; // nothing close enough: keep the original cut, the fade still helps } /** Mix all channels to one array so every channel is cut at the same sample. */ function mixDown(channels) { if (channels.length === 1) return channels[0]; const out = new Float32Array(channels[0].length); for (const ch of channels) for (let i = 0; i < out.length; i++) out[i] += ch[i] / channels.length; return out; } /** Equal-power fade in and out, applied in place. */ export function applyFades(samples, fadeLength) { const n = Math.min(fadeLength, Math.floor(samples.length / 2)); for (let i = 0; i < n; i++) { const g = Math.sin((i / n) * (Math.PI / 2)); samples[i] *= g; samples[samples.length - 1 - i] *= g; } return samples; } /** * Cut [startSec, endSec) out of `channels` (an array of Float32Array, one per channel). * Cut points snap to a nearby zero crossing, then get a short fade. */ export function cleanCut(channels, sampleRate, startSec, endSec, { snapMs = 5, fadeMs = 8 } = {}) { const length = channels[0].length; const clamp = (i) => Math.max(0, Math.min(length, i)); const radius = Math.round((snapMs / 1000) * sampleRate); const mono = mixDown(channels); let start = nearestZeroCrossing(mono, clamp(Math.round(startSec * sampleRate)), radius); let end = nearestZeroCrossing(mono, clamp(Math.round(endSec * sampleRate)), radius); if (end <= start) throw new RangeError('end must be after start'); const fade = Math.round((fadeMs / 1000) * sampleRate); return { start, end, channels: channels.map((ch) => applyFades(ch.slice(start, end), fade)), }; } /** Largest jump between neighbouring samples in the first and last few samples: a rough click meter. */ export function edgeJump(samples, window = 4) { let max = Math.abs(samples[0]) + Math.abs(samples[samples.length - 1]); for (const [a, b] of [[0, window], [samples.length - window, samples.length]]) for (let i = Math.max(1, a); i < b; i++) max = Math.max(max, Math.abs(samples[i] - samples[i - 1])); return max; } edgeJump is not part of the fix. It's a rough meter for tests: it looks at the first and last few samples and returns the biggest step it finds, including the step from silence to the first sample and from the last sample back to silence. A clean cut should score close to zero.
Testing it in Node
Because the core takes arrays, not an AudioContext, we can test it with a generated sine wave and node:test:
import test from 'node:test'; import assert from 'node:assert/strict'; import { nearestZeroCrossing, applyFades, cleanCut, edgeJump } from './declick.mjs'; const rate = 48000; const sine = (hz, sec, amp = 0.8) => Float32Array.from({ length: Math.round(rate * sec) }, (_, i) => amp * Math.sin(2 * Math.PI * hz * i / rate)); test('finds the nearest sign change', () => { const s = Float32Array.from([0.5, 0.4, 0.2, -0.1, -0.3, -0.2, 0.1]); assert.equal(nearestZeroCrossing(s, 1, 3), 3); assert.equal(nearestZeroCrossing(s, 5, 3), 6); }); test('keeps the original index when nothing is in range', () => { const s = new Float32Array(100).fill(0.5); assert.equal(nearestZeroCrossing(s, 50, 10), 50); }); test('fades start and end at silence and leave the middle alone', () => { const s = applyFades(new Float32Array(1000).fill(1), 100); assert.equal(s[0], 0); assert.ok(s[999] < 0.02); assert.equal(s[500], 1); }); test('a raw cut mid-wave jumps, a clean cut does not', () => { const tone = sine(220, 1); const raw = tone.slice(Math.round(0.1234 * rate), Math.round(0.6789 * rate)); const { channels } = cleanCut([tone], rate, 0.1234, 0.6789); const before = edgeJump(raw), after = edgeJump(channels[0]); console.log(`edge jump raw=${before.toFixed(3)} clean=${after.toFixed(4)}`); assert.ok(before > 0.5); assert.ok(after < 0.01); }); test('stereo channels are cut at the same samples', () => { const l = sine(220, 0.5), r = sine(330, 0.5, 0.4); const out = cleanCut([l, r], rate, 0.1, 0.4); assert.equal(out.channels.length, 2); assert.equal(out.channels[0].length, out.channels[1].length); assert.equal(out.channels[0].length, out.end - out.start); }); test('rejects an end before the start', () => { assert.throws(() => cleanCut([sine(220, 0.5)], rate, 0.4, 0.1), RangeError); }); The interesting test cuts a 220 Hz tone at two awkward times, once with a raw slice and once with cleanCut. Running node --test gives:
edge jump raw=1.278 clean=0.0007 ✔ finds the nearest sign change (1.735194ms) ✔ keeps the original index when nothing is in range (0.163684ms) ✔ fades start and end at silence and leave the middle alone (0.913533ms) ✔ a raw cut mid-wave jumps, a clean cut does not (13.270115ms) ✔ stereo channels are cut at the same samples (7.195042ms) ✔ rejects an end before the start (3.9841ms) ℹ tests 6 ℹ suites 0 ℹ pass 6 ℹ fail 0 ℹ cancelled 0 ℹ skipped 0 ℹ todo 0 ℹ duration_ms 124.60751 The raw cut starts and ends with a big step. The clean cut's edges are close to silence, which is what you want to hear.
Using it in the browser
In the page, the job is to get samples out of a file and an AudioBuffer back in. decodeAudioData does the first half, and copyToChannel does the second:
import { cleanCut } from './declick.mjs'; // Decode a File (from an <input type="file"> or a drop) into an AudioBuffer. export async function decodeFile(ctx, file) { return ctx.decodeAudioData(await file.arrayBuffer()); } // Cut a clean clip and return it as a new AudioBuffer you can play or export. export function cutBuffer(ctx, buffer, startSec, endSec) { const input = Array.from({ length: buffer.numberOfChannels }, (_, c) => buffer.getChannelData(c)); const { channels } = cleanCut(input, buffer.sampleRate, startSec, endSec); const clip = new AudioBuffer({ numberOfChannels: channels.length, length: channels[0].length, sampleRate: buffer.sampleRate, }); channels.forEach((data, c) => clip.copyToChannel(data, c)); return clip; } // Preview just the clip. export function play(ctx, clip) { const node = new AudioBufferSourceNode(ctx, { buffer: clip }); node.connect(ctx.destination); node.start(); return node; } One detail is easy to miss: decodeAudioData resamples to the context's sample rate. In a test in Chrome, an 8 kHz WAV came back at the context's rate:
chrome 156.0.8078.47 {"inSec": "1.000", "rate": 48000, "clipSec": "0.500", "first": "0.0000", "last": "0.0000"} So always read buffer.sampleRate from the decoded buffer when you convert seconds to sample indexes, never from the original file. The first and last samples of the clip are zero, as the fades intend.
Choosing the numbers
The defaults in cleanCut are a 5 ms snap window and an 8 ms fade. Some guidelines if you change them:
- Keep the snap window small. For speech and short effects a few milliseconds is plenty. A larger window finds a crossing more often but moves the cut further from where it was placed, and on a precise edit that's noticeable.
- Make the fade shorter than the attack you care about. A drum hit or a sharp "bruh" has its punch in the first few milliseconds. A long fade-in softens it. If the start sounds dull, shorten the fade before anything else.
- Fade out a little longer than you fade in, if you want. Endings are more forgiving, and a slightly longer fade-out hides reverb tails that were cut off.
- Never fade more than half the clip. The function caps the fade at half the length so a very short clip doesn't fade in and out at the same time.
What this doesn't solve
A clean edge won't fix everything:
- Background noise. If the recording has room noise or hiss, the clip still starts with noise. Cutting at a quiet point between words helps more than any fade.
- Lossy export. If you encode the clip to MP3, the encoder adds a small amount of padding at the start. That's a property of the format. If timing has to be exact, keep a WAV copy.
- Cutting mid-word. No amount of smoothing makes a clipped syllable sound whole. Move the cut.
If you just need the clip
All of this is worth knowing if you're building an editor. If you only need to cut a sound, a tool will do it faster. Meme Tap's online audio trimmer takes an MP3, WAV or OGG up to 40 MB, lets you drag start and end on a waveform or type them to two decimal places, plays only the selection, and downloads a WAV or an MP3. The file is read in the browser tab and isn't uploaded. If the cut still ticks, nudge the start a few hundredths of a second to a quieter spot.
Once the clip is clean, the audio visualizer turns it into a waveform, frequency-bar or circular video in 16:9, 9:16 or 1:1, exported as WebM with the audio. Both are listed with the caption generator on the Meme Tap tools page.
Summary
- A click is a sudden jump from silence to a non-zero sample, or back.
- Snap each cut to the nearest zero crossing within a few milliseconds, found on a mixed-down copy so all channels stay in sync.
- Add an equal-power fade of a few milliseconds at both ends.
- Read the sample rate from the decoded buffer, because
decodeAudioDataresamples. - Keep the core on plain arrays so you can test it in Node.
I'm Abid Ali, a freelance full-stack developer in Karachi working with TypeScript, Next.js, React and Node.js. I built Meme Tap, which has free meme and audio tools that run in the browser.