Spaces:
Sleeping
Sleeping
File size: 6,185 Bytes
b64de39 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 | /**
* VoiceTranscript - Real-time voice transcript display.
*
* Renders as a floating card above the VoiceButton showing:
* - Recording indicator with live dot
* - Final and interim transcript text
* - Command recognition feedback (success / unrecognized)
* - Animated voice wave bars while listening
*/
import { motion } from "framer-motion";
import {
CheckCircleIcon,
XCircleIcon,
SpeakerWaveIcon,
} from "@heroicons/react/24/outline";
import PropTypes from "prop-types";
import { INTENTS } from "@services/voiceService";
import { LANGUAGES } from "@utils/constants";
// ---------------------------------------------------------------------------
// Localized Labels
// ---------------------------------------------------------------------------
const LABELS = {
listening: {
[LANGUAGES.EN]: "Listening...",
[LANGUAGES.HI]: "सुन रहे हैं...",
},
processing: {
[LANGUAGES.EN]: "Processing...",
[LANGUAGES.HI]: "प्रसंस्करण...",
},
noSpeech: {
[LANGUAGES.EN]: "Say a command...",
[LANGUAGES.HI]: "कमांड बोलें...",
},
};
// ---------------------------------------------------------------------------
// Wave Bar Configuration
// ---------------------------------------------------------------------------
const WAVE_BAR_COUNT = 5;
const WAVE_HEIGHTS = [4, 16, 8, 20, 4];
const WAVE_DURATION = 1.2;
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
function VoiceTranscript({
transcript,
interimTranscript,
isListening,
commandResult,
language,
onClose,
}) {
const hasContent = Boolean(transcript || interimTranscript);
const isCommandRecognized =
commandResult && commandResult.intent !== INTENTS.UNKNOWN;
const lang = language || LANGUAGES.EN;
return (
<motion.div
initial={{ opacity: 0, y: 10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.95 }}
transition={{ duration: 0.2 }}
className="w-72 bg-white rounded-xl shadow-card-hover border border-neutral-200 overflow-hidden"
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-2.5 bg-neutral-50 border-b border-neutral-100">
<div className="flex items-center gap-2">
{isListening && (
<span className="relative flex h-2.5 w-2.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-danger-400 opacity-75" />
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-danger-500" />
</span>
)}
<span className="text-xs font-medium text-neutral-600">
{isListening
? LABELS.listening[lang] || LABELS.listening[LANGUAGES.EN]
: commandResult
? LABELS.processing[lang] || LABELS.processing[LANGUAGES.EN]
: ""}
</span>
</div>
<button
type="button"
onClick={onClose}
className="p-1 rounded-md text-neutral-400 hover:text-neutral-600 hover:bg-neutral-100 transition-colors"
aria-label="Close transcript"
>
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{/* Transcript content */}
<div className="px-4 py-3 min-h-[3rem]">
{!hasContent && isListening && (
<p className="text-sm text-neutral-400 italic">
{LABELS.noSpeech[lang] || LABELS.noSpeech[LANGUAGES.EN]}
</p>
)}
{transcript && (
<p className="text-sm text-neutral-800 font-medium">{transcript}</p>
)}
{interimTranscript && (
<p className="text-sm text-neutral-400 italic mt-1">
{interimTranscript}
</p>
)}
</div>
{/* Command result feedback */}
{commandResult && (
<div
className={[
"flex items-center gap-2 px-4 py-2.5 border-t text-sm",
isCommandRecognized
? "bg-primary-50 border-primary-100 text-primary-800"
: "bg-secondary-50 border-secondary-100 text-secondary-800",
].join(" ")}
>
{isCommandRecognized ? (
<CheckCircleIcon className="h-4 w-4 shrink-0 text-primary-600" />
) : (
<XCircleIcon className="h-4 w-4 shrink-0 text-secondary-600" />
)}
<span className="flex-1">{commandResult.response}</span>
{isCommandRecognized && (
<SpeakerWaveIcon className="h-4 w-4 shrink-0 text-primary-400" />
)}
</div>
)}
{/* Voice wave visualization */}
{isListening && (
<div className="flex items-end justify-center gap-0.5 px-4 py-2 border-t border-neutral-100">
{Array.from({ length: WAVE_BAR_COUNT }).map((_, i) => (
<motion.div
key={i}
className="w-1 rounded-full bg-primary-500"
animate={{ height: WAVE_HEIGHTS }}
transition={{
duration: WAVE_DURATION,
repeat: Infinity,
ease: "easeInOut",
delay: i * 0.15,
}}
/>
))}
</div>
)}
</motion.div>
);
}
VoiceTranscript.propTypes = {
transcript: PropTypes.string,
interimTranscript: PropTypes.string,
isListening: PropTypes.bool.isRequired,
commandResult: PropTypes.shape({
intent: PropTypes.string.isRequired,
confidence: PropTypes.number.isRequired,
route: PropTypes.string,
response: PropTypes.string.isRequired,
}),
language: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
};
export default VoiceTranscript;
|