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…

"use client"

import * as React from "react"
import { Play, Scan, Square, ZoomIn, ZoomOut } from "lucide-react"
import { cn } from "@/lib/utils"

/** The kept region, in seconds from the start of the clip. */
export interface AudioTrimmerRange {
  /** In point. */
  startS: number
  /** Out point. */
  endS: number
}

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/audio-trimmer.json

Prompt

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 + duration or an AudioBuffer you 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 minDurationS wide when the handles meet, instead of collapsing it to zero or letting it invert. The same legaliser also runs over the incoming value, 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 settlesonChange fires on every drag frame and every key step so a readout can follow the finger, while onCommit fires 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 off onCommit.
  • 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.

On This Page