Media
Audio Trimmer
A waveform you cut on: two handles bound the kept region, everything outside dims, zoom follows the selection, and a preview starts at the in point and stops itself at the out point.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/audio-trimmer.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "AudioTrimmer" component: the surface you cut
a clip on. Dependencies: lucide-react (Play, Square, ZoomIn, ZoomOut, Scan) and a
cn() class merger. No audio library, no wavesurfer, no state-machine library — the
camera, the drag model, the keyboard map and the SVG waveform are all local. It is
a "use client" component (pointer events, useId, a DOM measurement).
Contract
- export const AudioTrimmer = React.forwardRef<HTMLDivElement, AudioTrimmerProps>,
with displayName and a default export. Remaining native div props spread on the
root, className merged through cn().
- export interface AudioTrimmerRange { startS: number; endS: number } — the kept
region, in seconds from the start of the clip. This is the only value the
component ever emits.
- The SOURCE is a union, and it exists to say what this component refuses to do:
{ peaks: number[]; duration: number; buffer?: undefined }
| { buffer: AudioBuffer; peaks?: undefined; duration?: number }
It never fetches and never decodes. Downloading belongs to your data layer, and
one waveform must not open an AudioContext per clip. `peaks` wins whenever it is
present — including `peaks={[]}`, which means "the peaks have not arrived yet",
not "compute them from the buffer".
- AudioTrimmerProps = Omit<React.HTMLAttributes<HTMLDivElement>,
"defaultValue" | "onChange"> & that union & :
value?: AudioTrimmerRange controlled selection; pass it with onChange
defaultValue?: AudioTrimmerRange uncontrolled seed; omitted = the whole clip
onChange?(range) every frame of a drag and every key step
onCommit?(range) once per settled gesture — wire re-encoding here
minDurationS = 0.5 shortest kept region; the handles push, never cross
stepS = 0.01 arrow step; set it to 1/fps to cut on a frame grid
coarseStepS = 1 Shift+arrow; PageUp/PageDown move ten of these
currentTime?: number controlled playhead, pushed from YOUR transport
playing = false true while that transport runs
onPreviewStart?(range) omit it and no transport button renders at all
onPreviewStop?() stop pressed, or the out point was reached
resolution = 1024 buckets computed from `buffer`; ignored with peaks
barCount = 128 bars drawn across the VISIBLE window
height = 96 CSS px; width always fills the container
disabled = false read-only, see Behavior
label = "Trim audio" accessible name of the whole control
- Every numeric prop is defended before use, because a zero silently kills the
control (a 0 step makes each arrow press a no-op, a 0 bar count draws nothing, a
0 height leaves nothing to grab):
step = finite && > 0 ? max(stepS, 1e-6) : 0.01
coarse = finite && > 0 ? coarseStepS : step * 10
bars = round(clamp(barCount, 16, 512))
height = max(40, finite ? height : 96)
buckets= round(clamp(resolution, 64, 8192))
- Precision is derived from the step, not configured separately: decimalsOf(step)
= how many decimals it takes to write the step exactly (capped at 6). The snap
grid rounds at that precision; the readouts and aria-valuetext use
min(3, that) — so the grid and the numbers on screen can never disagree, and a
stepS of 1 gives whole-second readouts for free.
Behavior
- ONE legaliser, normalizeRange(range, duration, minDuration, step, decimals),
applied to drags, to key steps AND to the incoming `value` prop:
duration <= 0 -> { startS: 0, endS: 0 }
floor = min(minDurationS, duration) // a clip shorter than the minimum
// keeps all of itself instead of
// becoming untrimmable
snap both ends to the grid, clamp into [0, duration]
end < start -> swap them
end - start < floor -> push end out; if that overflows the clip,
pin end to duration and pull start back
An inverted or out-of-bounds range from the consumer therefore still renders a
real span — normalised FOR DISPLAY ONLY, never emitted back. Nothing leaves the
component that the consumer did not cause.
- Controlled or uncontrolled: rawRange = value ?? internal ?? { 0, duration }. A
change of clip duration is handled as a render-phase state adjust, not an
effect: when duration changes, the camera resets and (only when uncontrolled)
the selection returns to defaultValue in the SAME render — otherwise the first
paint shows a selection measured against a duration that no longer exists.
- Peaks are normalised against their own maximum, so arrays in any scale (0…1,
0…255, dB-ish) and quiet clips all render legibly; non-finite entries count as
silence; an all-zero array stays all-zero rather than dividing by nothing. Bars
are then bucketed across the VISIBLE window (peak per bucket), so zooming
re-samples the wave instead of stretching it.
- peaksFromBuffer(buffer, buckets): peak magnitude per bucket over the first two
channels, striding so no bucket reads more than ~512 samples. A 3-minute stereo
clip is ~16M samples and reading all of them blocks the main thread for tens of
ms to move a bar by less than a pixel.
- Geometry follows the DURATION, not the wave: with duration > 0 and no peaks yet
the clip is already trimmable against a flat baseline (bars have a minimum
height, so silence reads as a baseline, not as a hole). With duration = 0 the
rail renders an inert "No audio loaded" panel in role="status" and the handles
are not rendered at all.
- Pointer drag, per handle:
pointerdown left button only; measure the track width once; bail if it is 0;
preventDefault (kills the native text-selection drag), take
pointer capture, focus the handle deliberately so the keyboard
carries on from wherever the pointer left it; clear the pending
gesture; freeze { originX, originValue, width, windowLength }.
Both geometry values are frozen because the camera can pan
mid-drag and a pixel must keep meaning the same number of
seconds for the whole gesture.
pointermove DELTA, never an absolute map: grabbing a handle 5px off its
centre must nudge it, not teleport it onto the pointer.
seconds = (clientX - originX) / width * windowLength.
If event.buttons === 0 the release was swallowed (it happened
outside the window) — end the drag right there, because browsers
reuse pointer ids and a stale drag would follow an unpressed
hover.
end pointerup, pointercancel AND lostpointercapture all run the same
ender: drop the drag, release capture if still held, commit.
- applyEdge(edge, seconds) is the single write path: snap to the grid FIRST, then
clamp against the opposite edge (start into [0, end - floor], end into
[start + floor, duration]) — in that order, so the selection lands exactly
minDurationS wide when the handles meet, instead of collapsing to zero or
inverting. Then reveal the moved edge (see the camera), and emit only if the
range actually changed.
- Emit vs commit: emit() writes the internal copy (when uncontrolled), calls
onChange, and remembers the value as "the gesture in flight". commit() reads
that memo, clears it, and calls onCommit only if something is there — so a press
that moves nothing commits nothing, and a 300-frame drag produces one onCommit.
- Keyboard, on a focused handle (each handle owns its own bounds):
ArrowLeft / ArrowDown -1 step (Shift: coarse step)
ArrowRight / ArrowUp +1 step (Shift: coarse step)
PageDown / PageUp ∓10 coarse steps
Home this edge's minimum (0, or start + floor)
End this edge's maximum (end - floor, or duration)
Every handled key calls preventDefault AND stopPropagation: the host player
usually maps the same arrows globally, and one press must trim or seek, never
both. Key steps are discrete, so each press emits and commits immediately.
- Camera (zoom), kept strictly separate from the selection. The window is either
null (the whole clip) or { start, end }. Zoom out doubles the window, zoom in
halves it, both centred on the middle of the SELECTION; Fit selection sets the
window to keptLength * 1.3 — fitting exactly would park both handles on the rim
where they are hard to grab. The window never goes below max(duration / 200,
50ms), so a 2-second clip cannot zoom into a window with no bars in it, and any
window that reaches full length resets to null so "Full clip" has one
unambiguous state to test. The readout shows "—" with no clip, "Full clip"
unzoomed, or "3.2×".
- Reveal-by-pan: editing an edge, or merely FOCUSING a handle, pans the window
(never resizes it) until that time sits at least 6% inside it. At full clip
there is nothing to pan, so it is a no-op. An edge left outside the window by a
zoom is not unmounted — its handle is pinned to the rim at reduced opacity,
keeping its focus and its exact aria value, and focusing it pans the camera back
onto itself.
- Preview. The button only exists when onPreviewStart is given. Pressing it while
stopped calls onPreviewStart({ startS, endS }) — you seek and play; pressing it
while playing calls onPreviewStop. The auto-stop is a one-shot guard: it ARMS
only once currentTime has been SEEN inside [startS, endS), and only then does
currentTime >= endS fire onPreviewStop once and disarm. Without the arming, a
preview started while the transport still sits past the out point would be
stopped on its first frame, before the consumer's seek had landed. playing going
false disarms; a missing currentTime means no auto-stop at all. Keep the latest
onPreviewStop in a ref so the effect does not re-run on every render of the
parent.
- disabled is read-only, not dead: handles still render, still carry their aria
values and stay programmatically focusable, but they leave the tab order and
every edit and the transport are refused in the handlers. Zoom deliberately
stays live — a locked selection is exactly the one you want to inspect.
- Focus rescue: if the clip goes away while a handle had focus, the browser has
already dropped focus on <body>, where none of this component's keys would ever
be heard again. Move it to the track (tabIndex -1, only while there is no clip)
which replaced the handles — but only if focus really did fall through; a user
who tabbed away first has a real activeElement and is left alone.
- Cleanup is structural: no timers, no rAF, no window listeners, no observers —
the whole drag lives on the handle through pointer capture, and capture is
released on every ending path. The only refs are the pending gesture, the drag
state, the arming flag and the focus bookkeeping.
Rendering & styling
- Semantic tokens only, no hex / rgb / oklch. The clipped box is `border
bg-muted/40 text-primary`, and the bars are `fill-current` — so the wave's
colour is one token on one element. The whole clip is drawn dimmed at opacity
25, then the SAME element array is re-drawn through a clipPath rect covering the
kept region: the second pass costs no extra React work, the cut material stays
visible (you can see what you are about to throw away), and the boundary lands
mid-bar instead of quantising to whole bars. The clipPath id comes from useId,
stripped of characters that are illegal in url(#…).
- Selection frame: `border-y-2 border-primary/70`, pointer-events-none. Handles:
a 16px hit area, -translate-x-1/2, touch-none, `cursor-ew-resize`, containing a
`w-1.5 rounded-full bg-primary ring-1 ring-background` grip. Playhead: a 2px
bar in `bg-background ring-1 ring-foreground` — two tones on purpose, because in
a monochrome theme a single-colour playhead disappears into the wave it stands
on — drawn only while it is inside the window.
- The waveform lives in its own overflow-hidden box and the handles are SIBLINGS
of it, inside a px-2 gutter (half a handle), so a selection at 0% / 100% can
overhang instead of being sliced in half by the rounded corner or widening the
page.
- Readouts are `text-xs tabular-nums`: In / Out / Keeping, formatted m:ss.dd (or
h:mm:ss.dd past an hour) with integer maths — formatting the fraction from
(seconds - floor(seconds)) turns 12.999 into 12.100 once toFixed rounds up. Pad
with an explicit en-US Intl.NumberFormat: Intl.*(undefined) follows the visitor
and would render Eastern Arabic digits into a layout that assumes two glyphs.
- Buttons: the preview button is bg-primary / text-primary-foreground; zoom and
fit are size-7 ghost icon buttons (hover:bg-accent). All of them refuse through
aria-disabled plus a guard in the handler, never the native attribute — the
browser blurs a control the instant it goes disabled, and these reach their
limits under the user's finger.
- Accessibility: root role="group" + aria-label={label}. Each handle is
role="slider" aria-orientation="horizontal" labelled In point / Out point, with
aria-valuemin/max set to the bounds the OTHER edge imposes, aria-valuenow
rounded to the millisecond, and aria-valuetext reading like
"0:12.34, keeping 0:54.50" — the number a screen reader needs is the time plus
the length you are keeping, not a raw float. The SVG, the selection frame, the
playhead and every icon are aria-hidden. Focus rings are ring-2 ring-ring, with
ring-offset-background on the handles so the ring survives on top of the wave.
- Motion is decorative: the handle and the selection frame glide
(transition duration-100) for discrete jumps only — during a drag the transition
is switched OFF so the handle tracks the finger exactly — and every transition
carries motion-reduce:transition-none. With motion off, the component behaves
identically.
Customization levers
- Density: `height` (min 40; 64 for a compact row, 128+ for an editor) and
`barCount` (16…512; fewer, fatter bars read as a summary, more read as detail).
`resolution` only matters on the buffer path — it is how finely the clip is
bucketed once, independent of how many bars are drawn.
- Grids: `stepS` is the whole precision story — 0.01 for speech, 1/25 or 1/30 to
cut on a video's frame grid, 1 for a coarse whole-second trimmer (which also
drops the decimals from every readout). `coarseStepS` is the Shift step and one
tenth of a Page step. `minDurationS` is the refusal floor.
- Slots worth cutting: omit onPreviewStart and the transport disappears entirely
(the readouts re-flow); the zoom cluster is one flex row you can drop for short
clips; the In / Out / Keeping trio can become one "Keeping" readout on narrow
surfaces. What must NOT be cut: the aria-valuetext, which is the only way the
kept length is announced.
- Tokens: the wave follows `text-primary` on the clipped box, the cut material is
the same colour at opacity 25 and the frame is primary/70. Move the wave to
var(--chart-1) and the handles to bg-accent for an editor look; raise the dim
from 25 to ~40 when you want the discarded material to stay legible for review,
lower it when the kept region should dominate.
- Camera constants: max zoom 200×, minimum window 50ms, reveal margin 6%, fit
factor 1.3, page = 10 coarse steps. Raise the fit factor for more context around
the selection; lower the reveal margin to pan less eagerly.
- Wiring: stream onChange into a live readout, and put anything expensive
(re-encoding, a server round trip, a waveform re-render at a new resolution)
behind onCommit. For multiple kept regions, render several trimmers over one
peaks array rather than growing this one — a second range would change the drag
model, the aria contract and the meaning of the zoom.Concepts
- Peaks in, never a fetch — the component takes either precomputed
peaks+durationor anAudioBufferyou decoded yourself, and it does neither the download nor the decode. That keeps the network in your data layer, keeps one AudioContext per app instead of one per clip, and makes the same control work for a clip whose duration is known before its waveform is: geometry follows the duration, so a clip with no peaks yet is already trimmable against a flat baseline. - Delta drag on frozen geometry — pointer capture plus a delta (
pixels ÷ track width × window length) rather than a map from pointer position to time, so grabbing a handle slightly off centre nudges it instead of teleporting it. Track width and window length are frozen at press time: the camera may pan mid-gesture, but a pixel has to keep meaning the same number of seconds until the finger lifts. - Snap first, clamp second — the order is the whole refusal story. Snapping to the step grid and only then clamping against the opposite edge lands the selection exactly
minDurationSwide when the handles meet, instead of collapsing it to zero or letting it invert. The same legaliser also runs over the incomingvalue, so an inverted or out-of-range prop still paints a real span — normalised for display, never emitted back. - Camera versus selection — zoom changes what you can see, never what you keep. It halves and doubles around the middle of the selection, refuses to shrink past 50ms or 200×, and snaps back to a single “Full clip” state at the top. Editing or focusing a handle pans the window until that edge sits 6% inside it, and an edge that a zoom left off-screen keeps a handle pinned to the rim — still focusable, still announcing its exact value, and panning the camera back onto itself when you tab to it.
- Change streams, commit settles —
onChangefires on every drag frame and every key step so a readout can follow the finger, whileonCommitfires once per settled gesture: a pointer release, or the key press itself, since a key step is already settled. A press that moved nothing commits nothing, which is what makes it safe to hang re-encoding offonCommit. - A stop that arms before it fires — the auto-stop at the out point only arms after the playhead has been seen inside the selection. Without that latch, pressing Preview while the transport still sits past the out point would stop playback on its very first frame, before your seek had landed; with it, the trimmer stops the clip exactly once and then disarms itself.