feat:음성입력 실시간 미리보기 + 재클릭 중지 기능

continuous 모드로 변경, interimResults로 실시간 텍스트 표시
녹음 중 버튼 재클릭 시 중지, 아이콘 정지 모양으로 변경

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
김보곤
2026-02-09 21:52:45 +09:00
parent e1a9910939
commit e638d97d65

View File

@@ -54,63 +54,102 @@
}
// --- VoiceInputButton (Web Speech API STT) ---
function VoiceInputButton({ onResult, disabled, mode = 'replace' }) {
function VoiceInputButton({ onResult, disabled }) {
const [recording, setRecording] = useState(false);
const [interim, setInterim] = useState('');
const recognitionRef = useRef(null);
const isSupported = typeof window !== 'undefined' &&
(window.SpeechRecognition || window.webkitSpeechRecognition);
const toggle = (e) => {
e.preventDefault();
e.stopPropagation();
if (disabled || !isSupported) return;
if (recording) {
recognitionRef.current?.stop();
setRecording(false);
return;
}
const stopRecording = useCallback(() => {
recognitionRef.current?.stop();
recognitionRef.current = null;
setRecording(false);
setInterim('');
}, []);
const startRecording = useCallback(() => {
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
const recognition = new SR();
recognition.lang = 'ko-KR';
recognition.continuous = false;
recognition.interimResults = false;
recognition.continuous = true;
recognition.interimResults = true;
recognition.maxAlternatives = 1;
let finalText = '';
recognition.onresult = (event) => {
const text = event.results[0][0].transcript;
if (text) onResult(text, mode);
setRecording(false);
let interimText = '';
for (let i = event.resultIndex; i < event.results.length; i++) {
const transcript = event.results[i][0].transcript;
if (event.results[i].isFinal) {
finalText += transcript;
if (finalText) onResult(finalText);
finalText = '';
interimText = '';
} else {
interimText += transcript;
}
}
setInterim(interimText);
};
recognition.onerror = () => stopRecording();
recognition.onend = () => {
setRecording(false);
setInterim('');
recognitionRef.current = null;
};
recognition.onerror = () => setRecording(false);
recognition.onend = () => setRecording(false);
recognitionRef.current = recognition;
recognition.start();
setRecording(true);
setInterim('');
}, [onResult, stopRecording]);
const toggle = (e) => {
e.preventDefault();
e.stopPropagation();
if (disabled || !isSupported) return;
recording ? stopRecording() : startRecording();
};
useEffect(() => {
return () => { recognitionRef.current?.stop(); };
}, []);
if (!isSupported) return null;
return (
<button
type="button"
onClick={toggle}
disabled={disabled}
title={recording ? '녹음 중지' : '음성으로 입력'}
className={`inline-flex items-center justify-center w-8 h-8 rounded-full transition-all flex-shrink-0
${recording
? 'bg-red-500 text-white animate-pulse shadow-lg shadow-red-200'
: 'bg-gray-100 text-gray-500 hover:bg-blue-100 hover:text-blue-600'}
${disabled ? 'opacity-30 cursor-not-allowed' : 'cursor-pointer'}`}
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" />
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" />
</svg>
</button>
<div className="relative flex-shrink-0">
<button
type="button"
onClick={toggle}
disabled={disabled}
title={recording ? '녹음 중지 (클릭)' : '음성으로 입력'}
className={`inline-flex items-center justify-center w-8 h-8 rounded-full transition-all
${recording
? 'bg-red-500 text-white animate-pulse shadow-lg shadow-red-200'
: 'bg-gray-100 text-gray-500 hover:bg-blue-100 hover:text-blue-600'}
${disabled ? 'opacity-30 cursor-not-allowed' : 'cursor-pointer'}`}
>
{recording ? (
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<rect x="6" y="6" width="12" height="12" rx="2" />
</svg>
) : (
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" />
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" />
</svg>
)}
</button>
{recording && interim && (
<div className="absolute bottom-full mb-2 right-0 bg-gray-900 text-white text-xs px-3 py-1.5 rounded-lg shadow-lg whitespace-nowrap max-w-[250px] truncate z-50">
<span className="text-yellow-300 mr-1">...</span>{interim}
</div>
)}
</div>
);
}