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:
parent
0d0b17c8a0
commit
21ecf23d9a
2 changed files with 158 additions and 0 deletions
60
ui/src/components/VoiceMicButton.tsx
Normal file
60
ui/src/components/VoiceMicButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
98
ui/src/components/VoiceWaveform.tsx
Normal file
98
ui/src/components/VoiceWaveform.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue