Audio Note New
Record a voice note, scrub it on a waveform with mouse, touch or arrow keys, then send or re-record.
Installation
01
Install with the shadcn CLI. The NiceUI theme comes along the first time.
$ npx shadcn@latest add https://niceui.dev/r/ai-audio-recorder.json02
Or copy the source below (2 files). Dependencies: lucide-react.
Source
components/niceui/ai/io/AudioRecorder.tsx
import { useEffect, useMemo, useRef, useState } from 'react';
import { ArrowUp, Mic, Pause, Play, RotateCcw, Square, Trash2 } from 'lucide-react';
import { cn } from '@/lib/cn';
import { mmss, prefersReducedMotion, rnd } from './shared';
type Phase = 'idle' | 'recording' | 'ready' | 'sent';
const BARS = 48;
export type AudioRecorderProps = { maxSeconds?: number; onSend?: (seconds: number) => void; className?: string };
/** Record a voice note (simulated levels), scrub it on a waveform, then send. */
export function AudioRecorder({ maxSeconds = 60, onSend, className }: AudioRecorderProps) {
const [phase, setPhase] = useState<Phase>('ready');
const [levels, setLevels] = useState<number[]>(() => Array.from({ length: BARS }, (_, i) => 0.2 + rnd(i * 3.1) * 0.75 * Math.abs(Math.sin(i / 5))));
const [length, setLength] = useState(14.6);
const [elapsed, setElapsed] = useState(0);
const [pos, setPos] = useState(0.36);
const [playing, setPlaying] = useState(false);
const track = useRef<HTMLDivElement>(null);
const drag = useRef(false);
// Recording: grow the waveform from simulated levels.
useEffect(() => {
if (phase !== 'recording') return;
const start = performance.now();
const id = window.setInterval(() => {
const s = (performance.now() - start) / 1000;
setElapsed(s);
setLevels((l) => [...l.slice(-(BARS - 1)), 0.15 + Math.abs(Math.sin(s * 5)) * 0.7 * (0.4 + rnd(Math.floor(s * 10)) * 0.6)]);
if (s >= maxSeconds) {
setLength(s);
setPhase('ready');
}
}, 100);
return () => clearInterval(id);
}, [phase, maxSeconds]);
// Playback.
useEffect(() => {
if (!playing) return;
const id = window.setInterval(() => {
setPos((p) => {
const n = p + 0.05 / length;
if (n >= 1) {
setPlaying(false);
return 0;
}
return n;
});
}, 50);
return () => clearInterval(id);
}, [playing, length]);
const seek = (clientX: number) => {
const r = track.current?.getBoundingClientRect();
if (r) setPos(Math.min(1, Math.max(0, (clientX - r.left) / r.width)));
};
const shown = useMemo(() => levels.map((v) => (prefersReducedMotion() && phase === 'recording' ? 0.4 : v)), [levels, phase]);
return (
<div className={cn('w-full max-w-md rounded-2xl bg-ink-900 p-4 shadow-card', className)}>
<div className="mb-3 flex items-center justify-between text-xs">
<span className="font-medium text-ink-200">Voice note</span>
<span className="font-mono tabular-nums text-ink-500">
{phase === 'recording' ? mmss(elapsed) : phase === 'sent' ? 'Sent' : `${mmss(pos * length)} / ${mmss(length)}`}
</span>
</div>
<div className="flex items-center gap-3">
{phase === 'recording' ? (
<button onClick={() => (setLength(Math.max(1, elapsed)), setPhase('ready'), setPos(0))} aria-label="Stop recording" className="focus-ring flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-rose-500 text-white">
<Square className="h-4 w-4 fill-current" />
</button>
) : (
<button onClick={() => setPlaying((p) => !p)} disabled={phase !== 'ready'} aria-label={playing ? 'Pause' : 'Play'} className="focus-ring flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-brand-500 text-ink-950 transition-transform active:scale-95 disabled:opacity-40">
{playing ? <Pause className="h-4 w-4 fill-current" /> : <Play className="ml-0.5 h-4 w-4 fill-current" />}
</button>
)}
<div
ref={track}
role="slider"
tabIndex={phase === 'ready' ? 0 : -1}
aria-label="Playback position"
aria-valuemin={0}
aria-valuemax={Math.round(length)}
aria-valuenow={Math.round(pos * length)}
onKeyDown={(e) => {
if (e.key === 'ArrowRight') setPos((p) => Math.min(1, p + 1 / length));
if (e.key === 'ArrowLeft') setPos((p) => Math.max(0, p - 1 / length));
if (e.key === ' ') (e.preventDefault(), setPlaying((p) => !p));
}}
onPointerDown={(e) => {
if (phase !== 'ready') return;
drag.current = true;
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
seek(e.clientX);
}}
onPointerMove={(e) => drag.current && seek(e.clientX)}
onPointerUp={() => (drag.current = false)}
className="focus-ring relative flex h-12 min-w-0 flex-1 cursor-pointer touch-none items-center justify-between rounded-lg"
>
{shown.map((v, i) => {
const played = phase === 'ready' && i / BARS < pos;
return <span key={i} className={cn('w-[3px] shrink-0 rounded-full transition-colors', phase === 'recording' ? 'bg-rose-300/80' : played ? 'bg-brand-400' : 'bg-white/20')} style={{ height: `${Math.max(8, v * 100)}%` }} />;
})}
</div>
</div>
<div className="mt-4 flex items-center gap-2">
{phase === 'sent' ? (
<button onClick={() => (setPhase('ready'), setPos(0))} className="focus-ring flex h-9 items-center gap-1.5 rounded-lg px-3 text-xs text-ink-300 hover:bg-white/5">
<RotateCcw className="h-3.5 w-3.5" /> Record another
</button>
) : (
<>
<button
onClick={() => {
setPlaying(false);
setLevels([]);
setElapsed(0);
setPhase('recording');
}}
disabled={phase === 'recording'}
className="focus-ring flex h-9 items-center gap-1.5 rounded-lg px-3 text-xs text-ink-300 hover:bg-white/5 disabled:opacity-40"
>
<Mic className="h-3.5 w-3.5" /> {phase === 'recording' ? 'Recording…' : 'Re-record'}
</button>
<button onClick={() => (setPlaying(false), setPos(0), setPhase('idle'))} disabled={phase !== 'ready'} aria-label="Discard" className="focus-ring flex h-9 w-9 items-center justify-center rounded-lg text-ink-400 hover:bg-white/5 hover:text-rose-300 disabled:opacity-40">
<Trash2 className="h-3.5 w-3.5" />
</button>
<button
onClick={() => {
setPlaying(false);
setPhase('sent');
onSend?.(length);
}}
disabled={phase !== 'ready'}
className="focus-ring ml-auto flex h-9 items-center gap-1.5 rounded-full bg-brand-500 px-4 text-xs font-medium text-ink-950 transition-transform active:scale-95 disabled:bg-white/10 disabled:text-ink-500"
>
Send <ArrowUp className="h-3.5 w-3.5" />
</button>
</>
)}
</div>
{phase === 'idle' && <p className="mt-2 text-xs text-ink-500">Discarded. Press Re-record to start over.</p>}
</div>
);
}
export function AudioRecorderDemo() {
return <AudioRecorder />;
}components/niceui/ai/io/shared.ts
import { useEffect, useRef, useState } from 'react';
export const prefersReducedMotion = () => typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
/** Seconds elapsed while `active`, ticking at `fps`. */
export function useTicker(active: boolean, fps = 20) {
const [t, setT] = useState(0);
const base = useRef(0);
useEffect(() => {
if (!active) return;
const start = performance.now() - base.current * 1000;
const id = window.setInterval(() => {
base.current = (performance.now() - start) / 1000;
setT(base.current);
}, 1000 / fps);
return () => clearInterval(id);
}, [active, fps]);
const reset = () => {
base.current = 0;
setT(0);
};
return [t, reset] as const;
}
/** Deterministic pseudo-random 0..1. */
export const rnd = (n: number) => {
const x = Math.sin(n * 127.1 + 311.7) * 43758.5453;
return x - Math.floor(x);
};
export const mmss = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`;