Usage
useAudio loads a file and returns its peaks and a player. Pass both to Waveform.Root and put the parts you want inside it.
import { Waveform, useAudio, usePlayerState } from '@sillon/react'export function Episode({ src }) { const { peaks, player } = useAudio(src) const { playing } = usePlayerState(player) return ( <div className="flex items-center gap-4"> <button onClick={player.toggle}>{playing ? 'Pause' : 'Play'}</button> {/* Root needs a size; the parts fill it. */} <Waveform.Root peaks={peaks} player={player} className="h-16 flex-1"> {/* Placeholder bars while the file loads, fading into the real ones */} <Waveform.Skeleton className="bg-stone-300 opacity-50 transition-opacity" /> <Waveform.Shape className="transition-opacity in-data-[state=loading]:opacity-0"> <Waveform.Track className="bg-stone-300" /> <Waveform.Progress className="bg-stone-950" /> </Waveform.Shape> <Waveform.Cursor className="w-0.5 bg-orange-600" /> <Waveform.Time className="absolute top-full mt-1 text-sm" /> </Waveform.Root> </div> )}To read a .peaks file, or to draw a waveform with no playback, use usePeaks and usePlayer separately. The waveform appears once the whole file is read; pass progressive to draw it while a long file streams in.
Components
Each part does one job, and they all follow the same player. Press play and every tile below moves together.
Styling
Track and Progress are clipped to the bars, so any CSS background works: a color, a gradient, a pattern or an image.
Effects
Waveform.GL takes a few lines of GLSL that know about the waveform and the music: each bar's height, how long ago the playhead passed it, and how loud the song is right now. The sparks and dust are a small component on the same data, from useWaveform(). Nothing moves until you press play.
Visualizer
Visualizer draws the live spectrum of a player while it plays. It draws in the element's CSS color, so it follows your theme, dark mode included. Give it a fragment and it draws with WebGL instead, through your shader.
Regions
Waveform.Region marks a span of time. Drag across the waveform to mark a passage, then move it or pull its edges. player.setLoop() repeats it.
import { useEffect, useState } from 'react'import { Waveform, useAudio } from '@sillon/react'// Both grips: 8px wide, centered on their edge of the loop, with a line down the middle.const handle = `w-2 cursor-ew-resize data-[sillon-handle=start]:-translate-x-1/2 data-[sillon-handle=end]:translate-x-1/2 after:absolute after:inset-x-[3px] after:inset-y-0 after:rounded-[1px] after:bg-accent`export function Looper({ src }) { const { peaks, player } = useAudio(src) const [loop, setLoop] = useState({ start: 16, end: 31 }) useEffect(() => player.setLoop(loop), [player, loop]) return ( <Waveform.Root peaks={peaks} player={player} barRadius={1} contrast={2} onDragSelect={(start, end) => setLoop({ start, end })} className="h-28" > <Waveform.Shape> <Waveform.Track className="bg-track" /> <Waveform.Progress className="bg-ink" /> <Waveform.Region {...loop} className="bg-accent" /> </Waveform.Shape> <Waveform.Region {...loop} onChange={(start, end) => setLoop({ start, end })} className="cursor-grab touch-none bg-accent/12 active:cursor-grabbing" > <Waveform.RegionHandle side="start" className={handle} aria-label="Loop start" /> <Waveform.RegionHandle side="end" className={handle} aria-label="Loop end" /> </Waveform.Region> </Waveform.Root> )}Long files
An hour-long recording, from peaks made ahead of time: a 302 kB .peaks file instead of 55 MB of audio. Pinch to zoom (Ctrl+scroll with a mouse); scroll sideways or drag to pan. useZoom adds these gestures, and store.setView(start, end) sets the visible range.
Decoding and the .peaks format live in @sillon/core, which installs with @sillon/react and also works without React: make .peaks files with Peaks.encode(), or load peaks for your own drawing with streamPeaks().
import { Waveform, usePeaks, useWaveformStore, useZoom } from '@sillon/react'const SPANS = [ { label: '1 h', seconds: 3600 }, { label: '10 min', seconds: 600 }, { label: '1 min', seconds: 60 }, { label: '5 s', seconds: 5 },]export function Zoomable() { // Peaks made ahead of time: a .peaks file is read as is, no audio decoded. const { peaks } = usePeaks('/audio/hour.peaks') const store = useWaveformStore() // Pinch or Ctrl+scroll zooms; scrolling sideways or dragging pans. None of it re-renders. const zoom = useZoom(store, { min: 5, drag: true }) // The demo also eases between spans and adds an overview: // https://github.com/mattrothenberg/sillon/blob/main/apps/site/src/islands/Zoom.tsx return ( <> {SPANS.map((s) => ( <button key={s.seconds} onClick={() => zoom.zoomTo(s.seconds)}>{s.label}</button> ))} <Waveform.Root ref={zoom.ref} store={store} peaks={peaks} barRadius={1} interactive={false} className="h-40 touch-pan-y"> <Waveform.Shape> <Waveform.Track className="bg-ink" /> </Waveform.Shape> </Waveform.Root> </> )}Recording
useRecorder records from the microphone and returns peaks that fill in as you speak, so the waveform draws while you record. When you stop, it hands back the audio to play or upload, and the peaks are complete.
import { useEffect } from 'react'import { Waveform, usePlayer, usePlayerState, useRecorder, useWaveformStore } from '@sillon/react'export function VoiceNote() { const rec = useRecorder() const recording = rec.state === 'recording' // Plays the recording once it's stopped. const player = usePlayer(rec.url ?? undefined) const { playing } = usePlayerState(player) // The last 10 seconds while recording, the whole recording after. const store = useWaveformStore() useEffect(() => rec.peaks?.subscribe(() => { const end = rec.peaks!.duration if (rec.peaks!.complete) store.setView(0, null) else store.setView(Math.max(0, end - 10), Math.max(10, end)) }), [rec.peaks, store]) return ( <> <button onClick={recording ? rec.stop : rec.start}>{recording ? 'Stop' : 'Record'}</button> <button onClick={player.toggle} disabled={!rec.url}>{playing ? 'Pause' : 'Play'}</button> <Waveform.Root store={store} peaks={rec.peaks} player={rec.url ? player : null} progressive className="h-28"> <Waveform.Shape> <Waveform.Track className={recording ? 'bg-accent' : 'bg-track'} /> <Waveform.Progress className="bg-ink" /> </Waveform.Shape> </Waveform.Root> </> )}
Comments
Waveform.Markerpins anything to a time.useGroupsmerges markers that would overlap, anduseActivereturns what the playhead just passed. Click an avatar to play from that comment, or a number to see everyone who commented there.