feat(37-02): VoiceWaveform canvas component and VoiceMicButton

- VoiceWaveform: 80x32 canvas with Web Audio AnalyserNode (fftSize=64), 20 animated bars drawn from frequency data using --primary color
- VoiceMicButton: three visual states — idle (Mic icon), recording (VoiceWaveform + ring-2 ring-primary), processing (Loader2 animate-spin)
- All three states have correct aria-labels per UI spec copywriting contract
This commit is contained in:
Nexus Dev 2026-04-04 02:36:07 +00:00
parent 0d0b17c8a0
commit 21ecf23d9a
2 changed files with 158 additions and 0 deletions

View file

@ -0,0 +1,60 @@
import { Mic, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { VoiceWaveform } from "./VoiceWaveform";
import { useVadRecorder } from "../hooks/useVadRecorder";
interface VoiceMicButtonProps {
onTranscript: (text: string) => void;
disabled?: boolean;
}
export function VoiceMicButton({ onTranscript, disabled }: VoiceMicButtonProps) {
const { state, start, stop, mediaStream } = useVadRecorder({ onTranscript });
// Idle state (also used when disabled)
if (state === "idle") {
return (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={start}
disabled={disabled}
aria-label="Start voice input"
title="Start voice input"
>
<Mic className="h-4 w-4" />
</Button>
);
}
// Recording state — show waveform with primary ring, click to stop
if (state === "recording") {
return (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 ring-2 ring-primary"
onClick={stop}
aria-label="Recording — speak now"
title="Recording — speak now"
>
<VoiceWaveform stream={mediaStream} active={true} />
</Button>
);
}
// Processing state — transcribing, disabled
return (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
disabled
aria-label="Transcribing..."
title="Transcribing..."
>
<Loader2 className="h-4 w-4 animate-spin" />
</Button>
);
}

View file

@ -0,0 +1,98 @@
import { useRef, useEffect } from "react";
interface VoiceWaveformProps {
stream: MediaStream | null;
active: boolean; // controls animation loop
}
export function VoiceWaveform({ stream, active }: VoiceWaveformProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const audioCtxRef = useRef<AudioContext | null>(null);
const analyserRef = useRef<AnalyserNode | null>(null);
const sourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
const animFrameRef = useRef<number | null>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || !stream || !active) return;
// Create or resume AudioContext (lazily — reused across start/stop cycles)
if (!audioCtxRef.current) {
audioCtxRef.current = new AudioContext();
}
const audioCtx = audioCtxRef.current;
if (audioCtx.state === "suspended") {
audioCtx.resume();
}
// Set up analyser
const analyser = audioCtx.createAnalyser();
analyser.fftSize = 64; // 32 frequency bins
analyserRef.current = analyser;
const source = audioCtx.createMediaStreamSource(stream);
sourceRef.current = source;
source.connect(analyser);
const dataArray = new Uint8Array(analyser.frequencyBinCount); // 32 bins
const canvasWidth = canvas.width;
const canvasHeight = canvas.height;
const ctx2d = canvas.getContext("2d");
// Get primary color from CSS variable
const primaryColor =
getComputedStyle(document.documentElement).getPropertyValue("--primary").trim() ||
"#1e66f5";
const draw = () => {
analyser.getByteFrequencyData(dataArray);
if (ctx2d) {
ctx2d.clearRect(0, 0, canvasWidth, canvasHeight);
ctx2d.fillStyle = primaryColor;
// Draw 20 bars, skipping every other bin (using bins 0, 2, 4, ... 38)
const barCount = 20;
const barWidth = 2;
const barGap = 2;
const totalWidth = barCount * barWidth + (barCount - 1) * barGap;
const startX = Math.floor((canvasWidth - totalWidth) / 2);
for (let i = 0; i < barCount; i++) {
const binValue = dataArray[i * 2] ?? 0;
const barHeight = Math.max(2, (binValue / 255) * canvasHeight);
const x = startX + i * (barWidth + barGap);
const y = canvasHeight - barHeight;
ctx2d.fillRect(x, y, barWidth, barHeight);
}
}
animFrameRef.current = requestAnimationFrame(draw);
};
animFrameRef.current = requestAnimationFrame(draw);
return () => {
// Cleanup on unmount or when active becomes false
if (animFrameRef.current !== null) {
cancelAnimationFrame(animFrameRef.current);
animFrameRef.current = null;
}
source.disconnect();
sourceRef.current = null;
analyserRef.current = null;
// Do NOT close AudioContext — reuse across start/stop cycles
};
}, [stream, active]);
return (
<canvas
ref={canvasRef}
width={80}
height={32}
className="inline-block"
aria-hidden="true"
/>
);
}