| interface Props { |
| data: number[]; |
| height?: number; |
| color?: "cyber" | "danger"; |
| } |
|
|
| |
| export default function WaveformViewer({ |
| data, |
| height = 96, |
| color = "cyber", |
| }: Props) { |
| if (!data.length) { |
| return ( |
| <div className="panel-alt flex h-24 items-center justify-center font-mono text-xs text-ink-dim"> |
| waiting for audio… |
| </div> |
| ); |
| } |
|
|
| const stroke = color === "danger" ? "rgb(var(--danger))" : "rgb(var(--cyber))"; |
| const fill = |
| color === "danger" ? "rgb(var(--danger) / 0.13)" : "rgb(var(--cyber) / 0.13)"; |
|
|
| const W = 1000; |
| const H = height; |
| const max = Math.max(...data, 0.001); |
| const stepX = W / Math.max(data.length - 1, 1); |
|
|
| |
| let topPath = `M 0 ${H / 2}`; |
| let botPath = `M ${W} ${H / 2}`; |
| data.forEach((v, i) => { |
| const norm = v / max; |
| const x = i * stepX; |
| const yT = H / 2 - (norm * H * 0.45); |
| topPath += ` L ${x.toFixed(2)} ${yT.toFixed(2)}`; |
| }); |
| for (let i = data.length - 1; i >= 0; i -= 1) { |
| const norm = data[i] / max; |
| const x = i * stepX; |
| const yB = H / 2 + (norm * H * 0.45); |
| botPath += ` L ${x.toFixed(2)} ${yB.toFixed(2)}`; |
| } |
|
|
| const closed = `${topPath} L ${W} ${H / 2} ${botPath} Z`; |
|
|
| return ( |
| <div className="panel-alt overflow-hidden"> |
| <svg |
| viewBox={`0 0 ${W} ${H}`} |
| preserveAspectRatio="none" |
| className="block w-full" |
| style={{ height }} |
| aria-label="audio waveform" |
| > |
| {/* center line */} |
| <line |
| x1="0" |
| y1={H / 2} |
| x2={W} |
| y2={H / 2} |
| stroke="rgb(var(--line))" |
| strokeDasharray="4 4" |
| strokeWidth="0.5" |
| /> |
| <path d={closed} fill={fill} /> |
| <path d={topPath} stroke={stroke} strokeWidth="1.2" fill="none" /> |
| </svg> |
| </div> |
| ); |
| } |
|
|