AudioRecorder
Audio recording component with playback and save capabilities.
audio recorder
0:00
Also need Button.
components/ui/audio-recorder/index.tsx
"use client"
import { Button } from "@/components/ui/button"
import { ComponentProps, useCallback, useEffect, useRef, useState } from "react"
import { DownloadIcon, LoaderIcon, PauseIcon, PlayIcon, RecordIcon } from "./icons"
export function audioBufferToWav(buffer: AudioBuffer): ArrayBuffer {
const numOfChan = buffer.numberOfChannels
const length = buffer.length * numOfChan * 2 + 44
const result = new ArrayBuffer(length)
const view = new DataView(result)
const channels: Float32Array[] = []
let offset = 0
let pos = 0
const writeString = (str: string) => {
for (let i = 0; i < str.length; i++) {
view.setUint8(pos++, str.charCodeAt(i))
}
}
// Write WAV header
writeString("RIFF")
view.setUint32(pos, length - 8, true)
pos += 4
writeString("WAVE")
writeString("fmt ")
view.setUint32(pos, 16, true)
pos += 4
view.setUint16(pos, 1, true)
pos += 2 // PCM
view.setUint16(pos, numOfChan, true)
pos += 2
view.setUint32(pos, buffer.sampleRate, true)
pos += 4
view.setUint32(pos, buffer.sampleRate * numOfChan * 2, true)
pos += 4
view.setUint16(pos, numOfChan * 2, true)
pos += 2
view.setUint16(pos, 16, true)
pos += 2
writeString("data")
view.setUint32(pos, length - pos - 4, true)
pos += 4
// Extract channel data
for (let i = 0; i < numOfChan; i++) {
channels.push(buffer.getChannelData(i))
}
// Interleave samples (TypeScript-safe)
const maxSamples = buffer.length
while (offset < maxSamples) {
for (let i = 0; i < numOfChan; i++) {
const sample = channels[i]?.[offset] ?? 0 // Safe access with fallback
const clamped = Math.max(-1, Math.min(1, sample))
const scaled = clamped < 0 ? clamped * 0x8000 : clamped * 0x7fff
view.setInt16(pos, scaled | 0, true) // | 0 ensures integer
pos += 2
}
offset++
}
return result
}
export function AudioRecorder({ className, ...props }: ComponentProps<"div">) {
const audioPlayerRef = useRef<HTMLAudioElement>(null)
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
const chunksRef = useRef<Blob[]>([])
const streamRef = useRef<MediaStream | null>(null)
const audioBlobRef = useRef<Blob | null>(null)
const [analyser, setAnalyser] = useState<AnalyserNode | null>(null)
const [isRecording, setIsRecording] = useState(false)
const [isPlaying, setIsPlaying] = useState(false)
const [elapsed, setElapsed] = useState(0)
const [playbackTime, setPlaybackTime] = useState<number | null>(null)
const [status, setStatus] = useState<string | null>(null)
const [hasRecording, setHasRecording] = useState(false)
const [progress, setProgress] = useState(0)
// live recording timer — independent of the canvas, just counts up while recording
useEffect(() => {
if (!isRecording) return
const start = Date.now()
const id = setInterval(() => setElapsed((Date.now() - start) / 1000), 100)
return () => clearInterval(id)
}, [isRecording])
const handleStop = useCallback(() => {
const blob = new Blob(chunksRef.current, { type: "audio/webm" })
audioBlobRef.current = blob
if (audioPlayerRef.current) audioPlayerRef.current.src = URL.createObjectURL(blob)
setHasRecording(true)
// fresh recording, no playback position to show yet
setPlaybackTime(null)
}, [])
const toggleRecord = useCallback(async () => {
if (isRecording) {
mediaRecorderRef.current?.stop()
streamRef.current?.getTracks().forEach((t) => t.stop())
setIsRecording(false)
setAnalyser(null)
return
}
try {
setStatus(null)
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
streamRef.current = stream
const AudioCtx =
window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext
const audioCtx = new AudioCtx()
const analyserNode = audioCtx.createAnalyser()
analyserNode.fftSize = 2048
audioCtx.createMediaStreamSource(stream).connect(analyserNode)
const mr = new MediaRecorder(stream)
mediaRecorderRef.current = mr
chunksRef.current = []
mr.ondataavailable = (e) => chunksRef.current.push(e.data)
mr.onstop = handleStop
mr.start()
setIsRecording(true)
setElapsed(0)
setAnalyser(analyserNode)
} catch {
setStatus("mic access denied")
}
}, [isRecording, handleStop])
const togglePlay = useCallback(() => {
const player = audioPlayerRef.current
if (!player || !hasRecording) return
isPlaying ? player.pause() : player.play()
setIsPlaying(!isPlaying)
}, [isPlaying, hasRecording])
const seekAudio = useCallback((value: number) => {
const player = audioPlayerRef.current
if (!player || !player.duration) return
player.currentTime = (value / 100) * player.duration
}, [])
useEffect(() => {
const player = audioPlayerRef.current
if (!player) return
const onTimeUpdate = () => {
if (!player.duration) return
setProgress((player.currentTime / player.duration) * 100)
// also captures the exact position when paused, since currentTime
// doesn't change after pause — no separate "pause" listener needed
setPlaybackTime(player.currentTime)
}
const onEnded = () => {
setIsPlaying(false)
setProgress(0)
// played all the way through, fall back to showing total duration
setPlaybackTime(null)
}
player.addEventListener("timeupdate", onTimeUpdate)
player.addEventListener("ended", onEnded)
return () => {
player.removeEventListener("timeupdate", onTimeUpdate)
player.removeEventListener("ended", onEnded)
}
}, [])
// while recording: live recording timer
// otherwise: playback position if there is one (mid-play or paused), else the recorded duration
const displayTime = isRecording ? elapsed : (playbackTime ?? elapsed)
return (
<div
className={`w-full max-w-md space-y-4 rounded-xl border border-neutral-800 bg-neutral-900 p-4 ${className || ""}`}
{...props}
>
<div className="flex justify-between gap-4">
<p className="text-[0.6875rem] tracking-[0.075rem] text-neutral-500 uppercase">audio recorder</p>
{status && <p className="text-[0.6875rem] tracking-[0.075rem] text-neutral-500">{status}</p>}
</div>
<Dsplay
analyser={analyser}
isActive={isRecording}
className="rounded-xl border border-neutral-800 bg-neutral-900 py-1"
/>
<div className="flex items-center gap-3">
<RecordButton isRecording={isRecording} onClick={toggleRecord} disabled={isPlaying} />
<PlayButton isPlaying={isPlaying} disabled={!hasRecording || isRecording} onClick={togglePlay} />
<SeekBar progress={progress} onSeek={seekAudio} disabled={!hasRecording || isRecording} />
<Time time={displayTime} />
<DownloadButton blob={audioBlobRef.current} originalName="recording" disabled={!hasRecording || isRecording} />
<audio ref={audioPlayerRef} className="hidden" />
</div>
<DecorativeSpeakers />
</div>
)
}
export interface DisplayProps extends ComponentProps<"div"> {
analyser: AnalyserNode | null
isActive: boolean
}
export function Dsplay({ analyser, isActive, className, ...props }: DisplayProps) {
const BAR_WIDTH = 3
const BAR_GAP = 2
const SAMPLE_INTERVAL = 60 // ms
const LINE_STOP = 0.9
const LINE_START = 0.0
const LINE_WIDTH = 2
const canvasRef = useRef<HTMLCanvasElement>(null)
const dataArrayRef = useRef<Uint8Array<ArrayBuffer> | null>(null)
const animFrameRef = useRef(0)
const historyRef = useRef<number[]>([])
const lastSampleRef = useRef(0)
const drawBaseline = useCallback((ctx: CanvasRenderingContext2D, w: number, h: number) => {
const dpr = window.devicePixelRatio
const slot = (BAR_WIDTH + BAR_GAP) * dpr
const barHeight = 2 * dpr
const count = Math.ceil(w / slot)
ctx.fillStyle = "rgba(148,163,184,0.25)"
for (let i = 0; i < count; i++) {
ctx.fillRect(i * slot, (h - barHeight) / 2, BAR_WIDTH * dpr, barHeight)
}
}, [])
const drawIdle = useCallback(() => {
const canvas = canvasRef.current
if (!canvas) return
canvas.width = canvas.offsetWidth * window.devicePixelRatio
canvas.height = canvas.offsetHeight * window.devicePixelRatio
const ctx = canvas.getContext("2d")
if (!ctx) return
const { width: w, height: h } = canvas
ctx.clearRect(0, 0, w, h)
drawBaseline(ctx, w, h)
}, [drawBaseline])
const drawBars = useCallback(() => {
const canvas = canvasRef.current
const dataArray = dataArrayRef.current
if (!analyser || !dataArray || !canvas) return
animFrameRef.current = requestAnimationFrame(drawBars)
analyser.getByteTimeDomainData(dataArray)
let sumSquares = 0
for (let i = 0; i < dataArray.length; i++) {
const v = ((dataArray[i] ?? 128) - 128) / 128
sumSquares += v * v
}
const rms = Math.sqrt(sumSquares / dataArray.length)
const level = Math.min(1, rms * 4)
const dpr = window.devicePixelRatio
const slot = (BAR_WIDTH + BAR_GAP) * dpr
const { width: w, height: h } = canvas
const maxVisibleBars = Math.max(1, Math.ceil((w * LINE_STOP) / slot))
const targetX = maxVisibleBars * slot
const now = Date.now()
if (now - lastSampleRef.current >= SAMPLE_INTERVAL) {
lastSampleRef.current = now
const history = historyRef.current
history.push(level)
if (history.length > maxVisibleBars) history.shift()
}
const ctx = canvas.getContext("2d")
if (!ctx) return
ctx.clearRect(0, 0, w, h)
drawBaseline(ctx, w, h)
const history = historyRef.current
history.forEach((lvl, i) => {
const barHeight = Math.max(2 * dpr, lvl * h * 0.9)
const x = i * slot
ctx.fillStyle = "#fff"
ctx.fillRect(x, (h - barHeight) / 2, BAR_WIDTH * dpr, barHeight)
})
// red line travels from LINE_START_PERCENT → LINE_STOP_PERCENT as bars fill
const startX = w * LINE_START
const lineX =
history.length >= maxVisibleBars ? targetX : startX + (history.length / maxVisibleBars) * (targetX - startX)
ctx.fillStyle = "#f87171"
ctx.fillRect(lineX, 0, LINE_WIDTH * dpr, h)
}, [analyser, drawBaseline])
useEffect(() => {
drawIdle()
window.addEventListener("resize", drawIdle)
return () => window.removeEventListener("resize", drawIdle)
}, [drawIdle])
useEffect(() => {
if (isActive && analyser) {
dataArrayRef.current = new Uint8Array(new ArrayBuffer(analyser.fftSize))
historyRef.current = []
lastSampleRef.current = 0
drawBars()
} else {
cancelAnimationFrame(animFrameRef.current)
drawIdle()
}
return () => cancelAnimationFrame(animFrameRef.current)
}, [isActive, analyser, drawBars, drawIdle])
return (
<div className={`overflow-hidden ${className || ""}`} {...props}>
<canvas ref={canvasRef} className="block h-20 w-full" />
</div>
)
}
export interface RecordButtonProps extends ComponentProps<"button"> {
isRecording: boolean
}
export function RecordButton({ isRecording, ...props }: RecordButtonProps) {
return (
<Button
aria-label={isRecording ? "Stop recording" : "Start recording"}
variant="outline"
size="icon"
className={`${isRecording ? "border-red-400! bg-red-400/5! text-red-400! hover:bg-red-400/10!" : ""}`}
{...props}
>
<RecordIcon isRecording={isRecording} className={isRecording ? "animate-pulse" : ""} />
</Button>
)
}
export interface PlayButtonProps extends ComponentProps<"button"> {
isPlaying: boolean
}
export function PlayButton({ isPlaying, ...props }: PlayButtonProps) {
return (
<Button aria-label={isPlaying ? "Pause" : "Play"} variant="outline" size="icon" {...props}>
{isPlaying ? <PauseIcon /> : <PlayIcon />}
</Button>
)
}
export interface SeekBarProps extends Omit<ComponentProps<"div">, "onChange"> {
progress: number
disabled?: boolean
onSeek?: (value: number) => void
}
export function SeekBar({ progress, disabled, className, onSeek, ...props }: SeekBarProps) {
return (
<div className={`relative isolate h-5 flex-1 ${className || ""}`} {...props}>
<div className="absolute top-1/2 -z-10 h-1 w-full -translate-y-1/2 rounded-full bg-neutral-800" />
<div
className="pointer-events-none absolute top-1/2 -z-10 h-1 -translate-y-1/2 rounded-full bg-neutral-400 transition-[width] duration-100"
style={{ width: `${progress}%` }}
/>
<input
type="range"
min={0}
max={100}
value={progress}
disabled={disabled}
onChange={(e) => onSeek?.(Number(e.target.value))}
className={`absolute inset-0 h-full w-full appearance-none bg-transparent ${disabled ? "" : "cursor-pointer"} [&::-moz-range-thumb]:h-3 [&::-moz-range-thumb]:w-3 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-transparent [&::-moz-range-track]:bg-transparent [&::-webkit-slider-runnable-track]:bg-transparent [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-transparent`}
/>
</div>
)
}
export interface TimeProps extends ComponentProps<"span"> {
time: number
}
export function Time({ time, ...props }: TimeProps) {
return (
<span className="text-xs text-neutral-600 tabular-nums" {...props}>
{Math.floor(time / 60)}:{String(Math.floor(time % 60)).padStart(2, "0")}
</span>
)
}
export interface DownloadButtonProps extends React.ComponentProps<typeof Button> {
blob: Blob | null
originalName?: string
}
export function DownloadButton({ blob, originalName = "recording", disabled, ...props }: DownloadButtonProps) {
const [loading, setLoading] = useState(false)
const convertToWav = async () => {
if (!blob) return
setLoading(true)
try {
const arrayBuffer = await blob.arrayBuffer()
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer)
const wavBuffer = audioBufferToWav(audioBuffer) // pure JS function below
const wavBlob = new Blob([wavBuffer], { type: "audio/wav" })
const url = URL.createObjectURL(wavBlob)
const a = document.createElement("a")
a.href = url
a.download = `${originalName}-${Date.now()}.wav`
a.click()
URL.revokeObjectURL(url)
// Optional: close context to free memory
audioContext.close()
} catch (err) {
console.error("Conversion failed:", err)
alert("Failed to convert audio. Try recording in a different format or browser.")
} finally {
setLoading(false)
}
}
return (
<Button
aria-label="Download the recording"
variant="outline"
size="icon"
onClick={convertToWav}
disabled={!blob || loading || disabled}
{...props}
>
{loading ? <LoaderIcon className="animate-spin" /> : <DownloadIcon />}
</Button>
)
}
export function DecorativeSpeakers() {
const ASPECT_RATIO = 10 / 1
const DOT_SIZE = 3
const DOT_GAP = 5
const containerRef = useRef<HTMLDivElement>(null)
const [grid, setGrid] = useState({ cols: 0, rows: 0 })
useEffect(() => {
const container = containerRef.current
if (!container) return
const updateGrid = () => {
const { width, height } = container.getBoundingClientRect()
const cell = DOT_SIZE + DOT_GAP
const cols = Math.max(0, Math.floor((width + DOT_GAP) / cell))
const rows = Math.max(0, Math.floor((height + DOT_GAP) / cell))
setGrid({ cols, rows })
}
updateGrid()
const observer = new ResizeObserver(updateGrid)
observer.observe(container)
return () => observer.disconnect()
}, [])
const dots = Array.from({ length: grid.cols * grid.rows })
return (
<div ref={containerRef} className="relative w-full" style={{ aspectRatio: `${ASPECT_RATIO}` }}>
<div
className="absolute inset-0 grid place-content-center"
style={{
gridTemplateColumns: `repeat(${grid.cols}, ${DOT_SIZE}px)`,
gridTemplateRows: `repeat(${grid.rows}, ${DOT_SIZE}px)`,
gap: `${DOT_GAP}px`,
}}
>
{dots.map((_, i) => (
<span key={i} className="rounded-full bg-black" style={{ width: DOT_SIZE, height: DOT_SIZE }} />
))}
</div>
</div>
)
}
components/ui/audio-recorder/icons.tsx
import { ComponentProps } from "react"
const DEFAULT_ICON_SIZE = 14
export interface IconProps extends ComponentProps<"svg"> {
size?: number
}
export interface RecordIconProps extends IconProps {
isRecording?: boolean
transition?: number
}
export function RecordIcon({ size = DEFAULT_ICON_SIZE, isRecording, transition = 150, ...props }: RecordIconProps) {
const s = size / 1.75
const offset = (size - s) / 2
return (
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} fill="currentColor" {...props}>
<rect
x={offset}
y={offset}
width={s}
height={s}
rx={isRecording ? 0 : s / 2}
className="transition-[rx]"
style={{ transitionDuration: `${transition}ms` }}
/>
</svg>
)
}
export function PlayIcon({ size = DEFAULT_ICON_SIZE, ...props }: IconProps) {
// proportions match the original 14px path (left=3, top=2.5, dx=9, dy=4.5)
const left = (size * 3) / 14
const top = (size * 2.5) / 14
const dx = (size * 9) / 14
const dy = (size * 4.5) / 14
return (
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} fill="currentColor" {...props}>
<path d={`M${left} ${top}l${dx} ${dy}-${dx} ${dy}V${top}z`} />
</svg>
)
}
export function PauseIcon({ size = DEFAULT_ICON_SIZE, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} fill="currentColor" {...props}>
<rect x={size / 7} y={size / 7} width={size / 3.5} height={size - (size / 7) * 2} rx={size / 14} />
<rect
x={size - size / 7 - size / 3.5}
y={size / 7}
width={size / 3.5}
height={size - (size / 7) * 2}
rx={size / 14}
/>
</svg>
)
}
export function DownloadIcon({ size = DEFAULT_ICON_SIZE, ...props }: IconProps) {
// proportions match the original 12px path: stem at x=6 from y=1 (len 7),
// arrowhead at y=5 spanning x=3..9, base line at y=10 spanning x=1..11
const box = size - 2
const stemX = (box * 6) / 12
const stemTop = (box * 1) / 12
const stemLen = (box * 7) / 12
const armX = (box * 3) / 12
const armY = (box * 5) / 12
const armD = (box * 3) / 12
const baseX = (box * 1) / 12
const baseY = (box * 10) / 12
const baseLen = (box * 10) / 12
return (
<svg
width={box}
height={box}
viewBox={`0 0 ${box} ${box}`}
fill="none"
stroke="currentColor"
strokeWidth="1.5"
{...props}
>
<path
d={`M${stemX} ${stemTop}v${stemLen}M${armX} ${armY}l${armD} ${armD} ${armD}-${armD}M${baseX} ${baseY}h${baseLen}`}
/>
</svg>
)
}
export function LoaderIcon({ size = DEFAULT_ICON_SIZE, ...props }: IconProps) {
// proportions match the original 24px path: a ~270° arc of radius 9
// centered in the box, starting at its rightmost point
const box = size + 10
const center = box / 2
const radius = (box * 9) / 24
const scale = radius / 9
const dx = -6.219 * scale
const dy = -8.56 * scale
return (
<svg
width={size}
height={size}
viewBox={`0 0 ${box} ${box}`}
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d={`M${center + radius} ${center}a${radius} ${radius} 0 1 1 ${dx} ${dy}`} />
</svg>
)
}
components/examples/audio-recorder.tsx
import { AudioRecorder } from "@/components/ui/audio-recorder"
export function AudioRecorderExample() {
return <AudioRecorder />
}