Mischief

50 / Agent UI

Audio Player

A recording with its shape, its position and its words, so a voice note can be read as well as heard.

0:00 / 0:00

Installation

Copy the source into your project, or keep it behind a package.

npx shadcn@latest add Tinkerers-Labs/mischief-ui/audio-player
import { AudioPlayer } from "mischief-ui/audio-player"
Also installs
  • lucide-react

Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.

registry/default/audio-player/audio-player.tsx
"use client" import * as React from "react"import { Pause, Play } from "lucide-react" import {  RenderSurface,  useThemeColors,  type SurfaceColor,} from "@/registry/default/render-surface/render-surface"import { cn } from "@/lib/utils" export type TranscriptLine = {  /** Seconds from the start of the audio. */

Usage

export function VoiceNote({ recording, lines }) {
  return <AudioPlayer src={recording} waveform transcript={lines} />
}

Give it the peaks if you have them

Drawing a waveform means knowing the amplitude across the whole file, and the only way to learn that in a browser is to decode it. Decoding holds the audio uncompressed: an hour of speech is a few hundred megabytes of Float32, arriving all at once.

So peaks is a real prop rather than an internal detail. Compute them once where the file is uploaded, store them beside it, and every play after that skips the decode entirely.

<AudioPlayer src={url} waveform peaks={stored.peaks} />
Amplitudes from 0 to 1, one per bar. Anything from 32 to a few hundred reads well.

Without them it decodes, and stops short at maxDecodeBytes. Past that ceiling the audio still plays and only the picture is given up, which is the right way round.

A picture is not a control

The waveform is drawn on a canvas, and a canvas cannot be tabbed to, dragged with a keyboard, or read out. So the thing that seeks is an ordinary range input lying over the drawing, transparent, at the full size of it.

That leaves the picture optional and the control intact. Arrow keys step through the audio, Home and End reach either end, the value is announced as a position in minutes and seconds rather than as a number of seconds, and the focus ring is drawn around the whole scrubber.

The transcript shape

A line is a start, an optional end, the text, and optionally who said it. Every speech service emits at least that much, so a transcript from one of them normalises in a few lines rather than tying this component to whichever one you picked.

const lines = response.segments.map((segment) => ({
  start: segment.start,
  end: segment.end,
  text: segment.text,
}))
Whisper, in full.

Lines are buttons, so the transcript is a way to move through the audio and not only a thing to read. The line under the playhead is marked with aria-current and scrolls itself into view while the audio is playing, instantly where reduced motion is preferred.

Recordings that do not know how long they are

A file from MediaRecorder, which is what Voice Input hands you, carries no duration in its header. The browser reports Infinity for it until it is asked to look, which leaves a player with no scale to draw against and a scrubber with no end.

Seeking past the end is what makes it look. That happens here on the first metadata event, so a recording made in the same page plays with a real length like any other file.

API

srcstring | BlobA URL, or a recording you already hold.
waveformbooleanDraw the audio behind the scrubber.
peaksreadonly number[]Amplitudes from 0 to 1. Supplying these skips decoding.
transcriptreadonly TranscriptLine[]Timed lines, shown under the controls.
ratesreadonly number[]Speeds the button cycles through. Defaults to 1, 1.5 and 2.
maxDecodeBytesnumberAbove this the audio plays without a drawn waveform. Defaults to 40MB.
colorstringA theme token for the played portion. Defaults to "--primary".
labelstringNames the recording in every control's label.
...rootPropsHTMLAttributes<HTMLDivElement>Native root attributes.

TranscriptLine

startnumberSeconds from the beginning of the audio.
endnumberSeconds. Only needed when lines do not run back to back.
textstringWhat was said.
speakerstringWho said it, shown before the line.

Accessibility

Seeking is a native range input with an accessible name and a value announced as a position in minutes and seconds, so it works with arrow keys, Home and End, and a screen reader. The waveform behind it is decoration and carries nothing the time display does not. Transcript lines are buttons, which makes every line reachable without a pointer, and the current one is marked with aria-current rather than by colour alone. Play, speed and every transcript line meet the 44px touch target. Nothing autoplays.