mirror of
https://github.com/hanzoai/shadcn.git
synced 2026-11-03 03:18:52 +00:00
14 lines
25 KiB
JSON
14 lines
25 KiB
JSON
{
|
|
"name": "ai-voice",
|
|
"description": "Voice recognition and speech synthesis components",
|
|
"dependencies": [
|
|
"@hanzo/ui"
|
|
],
|
|
"files": [
|
|
{
|
|
"name": "ai-voice.tsx",
|
|
"content": "\"use client\"\n\n// WebSpeech API type declarations\nimport React, { useCallback, useEffect, useRef, useState } from \"react\"\nimport { Grid } from \"@hanzo/ui/grid\"\nimport {\n Mic,\n MicOff,\n Pause,\n Play,\n RotateCcw,\n Settings,\n Square,\n Volume2,\n VolumeX,\n} from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Badge } from \"@/registry/default/ui/badge\"\nimport { Button } from \"@/registry/default/ui/button\"\nimport {\n Card,\n CardContent,\n CardHeader,\n CardTitle,\n} from \"@/registry/default/ui/card\"\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from \"@/registry/default/ui/select\"\nimport { Slider } from \"@/registry/default/ui/slider\"\nimport { Switch } from \"@/registry/default/ui/switch\"\nimport { Textarea } from \"@/registry/default/ui/textarea\"\n\n// Web Speech API shapes, declared MODULE-LOCALLY and deliberately not in\n// `declare global`.\n//\n// The Speech API is only partly standardised, so how much of it lib.dom ships\n// varies by TypeScript version — and this file has now been broken from both\n// directions by that. Declaring the names globally MERGES with whatever the lib\n// provides instead of shadowing it, so every small difference became an error\n// (`readonly` vs mutable → TS2687, a second `[index: number]` → TS2374,\n// `error: string` against the real `SpeechRecognitionErrorCode` union → TS2717).\n// Deleting the declarations instead broke the opposite way on a lib that does\n// NOT carry them: TS2552 \"Cannot find name 'SpeechRecognitionEvent'\".\n//\n// Local names collide with neither. They describe exactly the surface this\n// component touches, and the version of lib.dom in use stops mattering.\ninterface SpeechAlternative {\n readonly transcript: string\n}\n\ninterface SpeechResult {\n readonly isFinal: boolean\n readonly [index: number]: SpeechAlternative\n}\n\ninterface SpeechResultList {\n readonly length: number\n readonly [index: number]: SpeechResult\n}\n\ninterface SpeechRecognitionEventLike {\n readonly resultIndex: number\n readonly results: SpeechResultList\n}\n\ninterface SpeechRecognitionErrorEventLike {\n readonly error: string\n}\n\ninterface SpeechRecognizer {\n continuous: boolean\n interimResults: boolean\n lang: string\n onresult: (event: SpeechRecognitionEventLike) => void\n onerror: (event: SpeechRecognitionErrorEventLike) => void\n onend: () => void\n start(): void\n stop(): void\n abort(): void\n}\n\n// The constructor is read off `window` through a narrow cast rather than a\n// `Window` augmentation, for the same reason: lib.dom may or may not already\n// declare `SpeechRecognition` there, and re-declaring it with a different type\n// is itself an error.\ntype SpeechRecognizerCtor = new () => SpeechRecognizer\n\nconst getSpeechRecognizerCtor = (): SpeechRecognizerCtor | undefined => {\n if (typeof window === \"undefined\") return undefined\n const w = window as unknown as {\n SpeechRecognition?: SpeechRecognizerCtor\n webkitSpeechRecognition?: SpeechRecognizerCtor\n }\n return w.webkitSpeechRecognition ?? w.SpeechRecognition\n}\n\ninterface VoiceProfile {\n id: string\n name: string\n language: string\n gender: \"male\" | \"female\" | \"neutral\"\n accent?: string\n speed?: number\n pitch?: number\n}\n\ninterface VoiceCommand {\n command: string\n confidence: number\n timestamp: number\n parameters?: Record<string, any>\n}\n\ninterface AIVoiceProps {\n onTranscript?: (text: string) => void\n onCommand?: (command: VoiceCommand) => void\n language?: string\n voice?: VoiceProfile\n wakeWord?: string\n autoStart?: boolean\n className?: string\n}\n\nconst defaultVoices: VoiceProfile[] = [\n {\n id: \"default-en-us\",\n name: \"English (US)\",\n language: \"en-US\",\n gender: \"neutral\",\n speed: 1.0,\n pitch: 1.0,\n },\n {\n id: \"default-en-gb\",\n name: \"English (UK)\",\n language: \"en-GB\",\n gender: \"neutral\",\n speed: 1.0,\n pitch: 1.0,\n },\n {\n id: \"default-es-es\",\n name: \"Spanish (Spain)\",\n language: \"es-ES\",\n gender: \"neutral\",\n speed: 1.0,\n pitch: 1.0,\n },\n {\n id: \"default-fr-fr\",\n name: \"French (France)\",\n language: \"fr-FR\",\n gender: \"neutral\",\n speed: 1.0,\n pitch: 1.0,\n },\n {\n id: \"default-de-de\",\n name: \"German (Germany)\",\n language: \"de-DE\",\n gender: \"neutral\",\n speed: 1.0,\n pitch: 1.0,\n },\n {\n id: \"default-ja-jp\",\n name: \"Japanese (Japan)\",\n language: \"ja-JP\",\n gender: \"neutral\",\n speed: 1.0,\n pitch: 1.0,\n },\n {\n id: \"default-zh-cn\",\n name: \"Chinese (Mandarin)\",\n language: \"zh-CN\",\n gender: \"neutral\",\n speed: 1.0,\n pitch: 1.0,\n },\n]\n\nconst commandPatterns = [\n { pattern: /^(start|begin|go)$/i, command: \"start\" },\n { pattern: /^(stop|end|halt)$/i, command: \"stop\" },\n { pattern: /^(pause|wait)$/i, command: \"pause\" },\n { pattern: /^(resume|continue)$/i, command: \"resume\" },\n { pattern: /^(clear|reset)$/i, command: \"clear\" },\n { pattern: /^volume (\\d+)$/i, command: \"volume\", extract: 1 },\n { pattern: /^speak (.+)$/i, command: \"speak\", extract: 1 },\n]\n\nexport function AIVoice({\n onTranscript,\n onCommand,\n language = \"en-US\",\n voice,\n wakeWord = \"hey assistant\",\n autoStart = false,\n className,\n}: AIVoiceProps) {\n const [isListening, setIsListening] = useState(false)\n const [isSpeaking, setIsSpeaking] = useState(false)\n const [transcript, setTranscript] = useState(\"\")\n const [finalTranscript, setFinalTranscript] = useState(\"\")\n const [currentVolume, setCurrentVolume] = useState(0)\n const [isWakeWordActive, setIsWakeWordActive] = useState(false)\n const [selectedVoice, setSelectedVoice] = useState<VoiceProfile>(\n voice || defaultVoices[0]\n )\n const [showSettings, setShowSettings] = useState(false)\n const [speechSpeed, setSpeechSpeed] = useState(1.0)\n const [speechPitch, setSpeechPitch] = useState(1.0)\n const [speechVolume, setSpeechVolume] = useState(0.8)\n const [error, setError] = useState<string | null>(null)\n const [audioLevel, setAudioLevel] = useState(0)\n\n const recognitionRef = useRef<SpeechRecognizer | null>(null)\n const synthRef = useRef<SpeechSynthesis | null>(null)\n const audioContextRef = useRef<AudioContext | null>(null)\n const analyserRef = useRef<AnalyserNode | null>(null)\n const microphoneRef = useRef<MediaStreamAudioSourceNode | null>(null)\n const animationFrameRef = useRef<number | null>(null)\n const canvasRef = useRef<HTMLCanvasElement>(null)\n\n // Initialize speech recognition\n useEffect(() => {\n const Recognizer = getSpeechRecognizerCtor()\n if (Recognizer) {\n recognitionRef.current = new Recognizer()\n\n if (recognitionRef.current) {\n recognitionRef.current.continuous = true\n recognitionRef.current.interimResults = true\n recognitionRef.current.lang = language\n\n recognitionRef.current.onresult = (event) => {\n let interimTranscript = \"\"\n let finalTranscriptText = \"\"\n\n for (let i = event.resultIndex; i < event.results.length; i++) {\n const result = event.results[i]\n if (result.isFinal) {\n finalTranscriptText += result[0].transcript\n } else {\n interimTranscript += result[0].transcript\n }\n }\n\n setTranscript(interimTranscript)\n\n if (finalTranscriptText) {\n setFinalTranscript((prev) => prev + finalTranscriptText)\n onTranscript?.(finalTranscriptText)\n\n // Check for wake word\n if (\n isWakeWordActive &&\n finalTranscriptText.toLowerCase().includes(wakeWord.toLowerCase())\n ) {\n setIsWakeWordActive(false)\n handleWakeWordDetected(finalTranscriptText)\n }\n\n // Check for voice commands\n checkForCommands(finalTranscriptText)\n }\n }\n\n recognitionRef.current.onerror = (event) => {\n setError(`Speech recognition error: ${event.error}`)\n setIsListening(false)\n }\n\n recognitionRef.current.onend = () => {\n setIsListening(false)\n }\n }\n }\n\n // Initialize speech synthesis\n if (typeof window !== \"undefined\") {\n synthRef.current = window.speechSynthesis\n }\n\n return () => {\n if (recognitionRef.current) {\n recognitionRef.current.stop()\n }\n if (animationFrameRef.current) {\n cancelAnimationFrame(animationFrameRef.current)\n }\n }\n }, [language, wakeWord, isWakeWordActive, onTranscript])\n\n // Auto start if enabled\n useEffect(() => {\n if (autoStart) {\n startListening()\n }\n }, [autoStart])\n\n // Initialize audio context and analyzer\n const initializeAudioContext = useCallback(async () => {\n try {\n const stream = await navigator.mediaDevices.getUserMedia({ audio: true })\n\n // `webkitAudioContext` is a vendor prefix lib.dom does not declare, so it\n // is read through the same narrow cast as the recogniser rather than by\n // augmenting Window (see the note at the top of this file).\n const AudioCtx =\n window.AudioContext ??\n (window as unknown as { webkitAudioContext?: typeof AudioContext })\n .webkitAudioContext\n if (!AudioCtx) throw new Error(\"Web Audio API is not available\")\n audioContextRef.current = new AudioCtx()\n analyserRef.current = audioContextRef.current.createAnalyser()\n microphoneRef.current =\n audioContextRef.current.createMediaStreamSource(stream)\n\n analyserRef.current.fftSize = 256\n microphoneRef.current.connect(analyserRef.current)\n\n startAudioVisualization()\n setError(null)\n } catch (err) {\n setError(\"Microphone access denied\")\n }\n }, [])\n\n // Audio visualization\n const startAudioVisualization = useCallback(() => {\n if (!analyserRef.current || !canvasRef.current) return\n\n const canvas = canvasRef.current\n const ctx = canvas.getContext(\"2d\")\n if (!ctx) return\n\n const bufferLength = analyserRef.current.frequencyBinCount\n const dataArray = new Uint8Array(bufferLength)\n\n const draw = () => {\n if (!analyserRef.current) return\n\n analyserRef.current.getByteFrequencyData(dataArray)\n\n // Calculate audio level\n const sum = dataArray.reduce((a, b) => a + b, 0)\n const level = sum / (bufferLength * 255)\n setAudioLevel(level)\n\n // Draw waveform\n ctx.fillStyle = \"rgb(0, 0, 0, 0.1)\"\n ctx.fillRect(0, 0, canvas.width, canvas.height)\n\n const barWidth = (canvas.width / bufferLength) * 2.5\n let barHeight\n let x = 0\n\n ctx.fillStyle = \"rgb(59, 130, 246)\"\n\n for (let i = 0; i < bufferLength; i++) {\n barHeight = (dataArray[i] / 255) * canvas.height * 0.8\n\n ctx.fillRect(x, canvas.height - barHeight, barWidth, barHeight)\n x += barWidth + 1\n }\n\n animationFrameRef.current = requestAnimationFrame(draw)\n }\n\n draw()\n }, [])\n\n const startListening = useCallback(async () => {\n if (!recognitionRef.current) {\n setError(\"Speech recognition not supported\")\n return\n }\n\n try {\n await initializeAudioContext()\n recognitionRef.current.start()\n setIsListening(true)\n setError(null)\n } catch (err) {\n setError(\"Failed to start listening\")\n }\n }, [initializeAudioContext])\n\n const stopListening = useCallback(() => {\n if (recognitionRef.current) {\n recognitionRef.current.stop()\n }\n setIsListening(false)\n\n if (animationFrameRef.current) {\n cancelAnimationFrame(animationFrameRef.current)\n }\n }, [])\n\n const speak = useCallback(\n (text: string) => {\n if (!synthRef.current) return\n\n synthRef.current.cancel()\n\n const utterance = new SpeechSynthesisUtterance(text)\n utterance.lang = selectedVoice.language\n utterance.rate = speechSpeed\n utterance.pitch = speechPitch\n utterance.volume = speechVolume\n\n utterance.onstart = () => setIsSpeaking(true)\n utterance.onend = () => setIsSpeaking(false)\n utterance.onerror = () => {\n setIsSpeaking(false)\n setError(\"Speech synthesis error\")\n }\n\n synthRef.current.speak(utterance)\n },\n [selectedVoice, speechSpeed, speechPitch, speechVolume]\n )\n\n const stopSpeaking = useCallback(() => {\n if (synthRef.current) {\n synthRef.current.cancel()\n setIsSpeaking(false)\n }\n }, [])\n\n const checkForCommands = useCallback(\n (text: string) => {\n const words = text.toLowerCase().trim()\n\n for (const { pattern, command, extract } of commandPatterns) {\n const match = words.match(pattern)\n if (match) {\n const voiceCommand: VoiceCommand = {\n command,\n confidence: 0.9,\n timestamp: Date.now(),\n parameters: extract ? { value: match[extract] } : undefined,\n }\n onCommand?.(voiceCommand)\n break\n }\n }\n },\n [onCommand]\n )\n\n const handleWakeWordDetected = useCallback(\n (text: string) => {\n speak(\"How can I help you?\")\n const afterWakeWord = text.split(wakeWord)[1]?.trim()\n if (afterWakeWord) {\n checkForCommands(afterWakeWord)\n }\n },\n [speak, wakeWord, checkForCommands]\n )\n\n const clearTranscript = useCallback(() => {\n setTranscript(\"\")\n setFinalTranscript(\"\")\n }, [])\n\n const handleKeyDown = useCallback(\n (event: React.KeyboardEvent) => {\n switch (event.key) {\n case \" \":\n event.preventDefault()\n if (isListening) {\n stopListening()\n } else {\n startListening()\n }\n break\n case \"Escape\":\n event.preventDefault()\n stopListening()\n stopSpeaking()\n break\n case \"Enter\":\n if (event.ctrlKey || event.metaKey) {\n event.preventDefault()\n if (finalTranscript.trim()) {\n speak(finalTranscript)\n }\n }\n break\n }\n },\n [\n isListening,\n startListening,\n stopListening,\n stopSpeaking,\n finalTranscript,\n speak,\n ]\n )\n\n return (\n <Grid\n columns={1}\n gap={16}\n className={cn(\"w-full max-w-2xl mx-auto\", className)}\n onKeyDown={handleKeyDown}\n tabIndex={0}\n >\n {/* Main Control Panel */}\n <Card>\n <CardHeader className=\"pb-3\">\n <CardTitle className=\"flex items-center justify-between\">\n AI Voice Interface\n <Button\n variant=\"outline\"\n size=\"sm\"\n onClick={() => setShowSettings(!showSettings)}\n >\n <Settings className=\"h-4 w-4\" />\n </Button>\n </CardTitle>\n </CardHeader>\n <CardContent>\n <Grid columns={1} gap={16}>\n {/* Audio Visualization */}\n <div className=\"relative h-24 bg-muted rounded-lg overflow-hidden\">\n <canvas\n ref={canvasRef}\n width={400}\n height={96}\n className=\"w-full h-full\"\n />\n <Grid\n columns={1}\n gap={0}\n className=\"absolute inset-0\"\n style={{ placeItems: \"center\" }}\n >\n {!isListening && audioLevel === 0 && (\n <div className=\"text-muted-foreground text-sm\">\n Click microphone to start listening\n </div>\n )}\n </Grid>\n </div>\n\n {/* Control Buttons */}\n <div className=\"flex items-center justify-center gap-4\">\n <Button\n variant={isListening ? \"destructive\" : \"default\"}\n size=\"lg\"\n className={cn(\n \"relative transition-all duration-200\",\n isListening && audioLevel > 0.1 && \"animate-pulse\"\n )}\n onClick={isListening ? stopListening : startListening}\n >\n {isListening ? (\n <MicOff className=\"h-5 w-5\" />\n ) : (\n <Mic className=\"h-5 w-5\" />\n )}\n <span className=\"ml-2\">\n {isListening ? \"Stop\" : \"Start\"} Listening\n </span>\n </Button>\n\n <Button\n variant={isSpeaking ? \"destructive\" : \"outline\"}\n size=\"lg\"\n onClick={\n isSpeaking ? stopSpeaking : () => speak(finalTranscript)\n }\n disabled={!finalTranscript.trim()}\n >\n {isSpeaking ? (\n <Square className=\"h-4 w-4\" />\n ) : (\n <Volume2 className=\"h-4 w-4\" />\n )}\n <span className=\"ml-2\">{isSpeaking ? \"Stop\" : \"Speak\"}</span>\n </Button>\n\n <Button variant=\"outline\" size=\"lg\" onClick={clearTranscript}>\n <RotateCcw className=\"h-4 w-4\" />\n <span className=\"ml-2\">Clear</span>\n </Button>\n </div>\n\n {/* Wake Word Toggle */}\n <Grid\n columns=\"minmax(0, 1fr) auto\"\n gap={16}\n style={{ alignItems: \"center\" }}\n >\n <Grid columns={1} gap={4}>\n <div className=\"text-sm font-medium\">Wake Word Detection</div>\n <div className=\"text-xs text-muted-foreground\">\n Say "{wakeWord}" to activate\n </div>\n </Grid>\n <Switch\n checked={isWakeWordActive}\n onCheckedChange={setIsWakeWordActive}\n />\n </Grid>\n\n {/* Status Indicators */}\n <div className=\"flex flex-wrap items-center gap-2\">\n {isListening && (\n <Badge variant=\"default\" className=\"animate-pulse\">\n Listening\n </Badge>\n )}\n {isSpeaking && (\n <Badge variant=\"secondary\" className=\"animate-pulse\">\n Speaking\n </Badge>\n )}\n {isWakeWordActive && (\n <Badge variant=\"outline\">Wake Word Active</Badge>\n )}\n {audioLevel > 0.1 && (\n <Badge variant=\"outline\">\n Audio: {Math.round(audioLevel * 100)}%\n </Badge>\n )}\n </div>\n\n {/* Error Display */}\n {error && (\n <div className=\"p-3 bg-destructive/10 border border-destructive/20 rounded-lg text-sm text-destructive\">\n {error}\n </div>\n )}\n </Grid>\n </CardContent>\n </Card>\n\n {/* Transcript Display */}\n <Card>\n <CardHeader className=\"pb-3\">\n <CardTitle className=\"text-lg\">Transcript</CardTitle>\n </CardHeader>\n <CardContent>\n <Grid columns={1} gap={8}>\n <Textarea\n value={finalTranscript + (transcript ? ` ${transcript}` : \"\")}\n onChange={(e) => setFinalTranscript(e.target.value)}\n placeholder=\"Transcript will appear here...\"\n className=\"min-h-[100px] resize-none\"\n readOnly={false}\n />\n <Grid\n columns=\"auto auto\"\n gap={8}\n className=\"text-xs text-muted-foreground\"\n style={{ justifyContent: \"space-between\" }}\n >\n <span>{finalTranscript.length} characters</span>\n <span>Ctrl/Cmd + Enter to speak</span>\n </Grid>\n </Grid>\n </CardContent>\n </Card>\n\n {/* Settings Panel */}\n {showSettings && (\n <Card>\n <CardHeader className=\"pb-3\">\n <CardTitle className=\"text-lg\">Voice Settings</CardTitle>\n </CardHeader>\n <CardContent>\n <Grid columns={1} gap={24}>\n {/* Voice Selection */}\n <Grid columns={1} gap={8}>\n <label className=\"text-sm font-medium\">Voice</label>\n <Select\n value={selectedVoice.id}\n onValueChange={(value) => {\n const voice = defaultVoices.find((v) => v.id === value)\n if (voice) setSelectedVoice(voice)\n }}\n >\n <SelectTrigger>\n <SelectValue />\n </SelectTrigger>\n <SelectContent>\n {defaultVoices.map((voice) => (\n <SelectItem key={voice.id} value={voice.id}>\n {voice.name}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </Grid>\n\n {/* Speech Controls. Three sliders where three fit, fewer where\n they do not — the floor measures the column, so `md:` never\n has to guess at the card's width. */}\n <Grid columns={{ min: 150, max: 3 }} gap={16}>\n <Grid columns={1} gap={8}>\n <label className=\"text-sm font-medium\">Speed</label>\n <Slider\n value={[speechSpeed]}\n onValueChange={([value]) => setSpeechSpeed(value)}\n min={0.5}\n max={2.0}\n step={0.1}\n className=\"w-full\"\n />\n <div className=\"text-xs text-muted-foreground text-center\">\n {speechSpeed.toFixed(1)}x\n </div>\n </Grid>\n\n <Grid columns={1} gap={8}>\n <label className=\"text-sm font-medium\">Pitch</label>\n <Slider\n value={[speechPitch]}\n onValueChange={([value]) => setSpeechPitch(value)}\n min={0.5}\n max={2.0}\n step={0.1}\n className=\"w-full\"\n />\n <div className=\"text-xs text-muted-foreground text-center\">\n {speechPitch.toFixed(1)}x\n </div>\n </Grid>\n\n <Grid columns={1} gap={8}>\n <label className=\"text-sm font-medium\">Volume</label>\n <Slider\n value={[speechVolume]}\n onValueChange={([value]) => setSpeechVolume(value)}\n min={0.0}\n max={1.0}\n step={0.1}\n className=\"w-full\"\n />\n <div className=\"text-xs text-muted-foreground text-center\">\n {Math.round(speechVolume * 100)}%\n </div>\n </Grid>\n </Grid>\n\n {/* Keyboard Shortcuts */}\n <Grid columns={1} gap={8}>\n <label className=\"text-sm font-medium\">\n Keyboard Shortcuts\n </label>\n <Grid\n columns={{ min: 200, max: 2 }}\n gap={8}\n className=\"text-xs\"\n >\n {[\n [\"Toggle listening:\", \"Space\"],\n [\"Stop all:\", \"Esc\"],\n [\"Speak transcript:\", \"Ctrl+Enter\"],\n ].map(([label, key]) => (\n <Grid\n key={key}\n columns=\"minmax(0, 1fr) auto\"\n gap={8}\n style={{ alignItems: \"center\" }}\n >\n <span>{label}</span>\n <kbd className=\"bg-muted px-1 rounded\">{key}</kbd>\n </Grid>\n ))}\n </Grid>\n </Grid>\n </Grid>\n </CardContent>\n </Card>\n )}\n </Grid>\n )\n}\n\nexport type { VoiceProfile, VoiceCommand, AIVoiceProps }\n"
|
|
}
|
|
],
|
|
"type": "components:ai"
|
|
} |