|
|
|
|
@@ -104,11 +104,14 @@ function StatusBadge({ status, size = 'sm' }) {
|
|
|
|
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
|
|
|
// RecorderWidget - Canvas 파형 + 원형 버튼
|
|
|
|
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
|
|
|
function RecorderWidget({ onDone }) {
|
|
|
|
|
function RecorderWidget({ onDone, categories }) {
|
|
|
|
|
const [phase, setPhase] = useState('idle'); // idle | recording | paused | saving
|
|
|
|
|
const [seconds, setSeconds] = useState(0);
|
|
|
|
|
const [title, setTitle] = useState('');
|
|
|
|
|
const [saveProgress, setSaveProgress] = useState(0);
|
|
|
|
|
const [transcript, setTranscript] = useState('');
|
|
|
|
|
const [interimTranscript, setInterimTranscript] = useState('');
|
|
|
|
|
const [selectedTemplateId, setSelectedTemplateId] = useState('');
|
|
|
|
|
|
|
|
|
|
const canvasRef = useRef(null);
|
|
|
|
|
const mediaRef = useRef(null);
|
|
|
|
|
@@ -118,6 +121,8 @@ function RecorderWidget({ onDone }) {
|
|
|
|
|
const audioCtxRef = useRef(null);
|
|
|
|
|
const timerRef = useRef(null);
|
|
|
|
|
const rafRef = useRef(null);
|
|
|
|
|
const recognitionRef = useRef(null);
|
|
|
|
|
const confirmedRef = useRef([]);
|
|
|
|
|
|
|
|
|
|
// Canvas 파형 그리기
|
|
|
|
|
const drawWaveform = useCallback(() => {
|
|
|
|
|
@@ -159,6 +164,57 @@ function RecorderWidget({ onDone }) {
|
|
|
|
|
rafRef.current = requestAnimationFrame(drawWaveform);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// Web Speech API - 실시간 음성인식
|
|
|
|
|
const startSpeechRecognition = () => {
|
|
|
|
|
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
|
|
|
|
if (!SpeechRecognition) return;
|
|
|
|
|
|
|
|
|
|
const recognition = new SpeechRecognition();
|
|
|
|
|
recognition.lang = 'ko-KR';
|
|
|
|
|
recognition.continuous = true;
|
|
|
|
|
recognition.interimResults = true;
|
|
|
|
|
recognition.maxAlternatives = 1;
|
|
|
|
|
|
|
|
|
|
confirmedRef.current = [];
|
|
|
|
|
|
|
|
|
|
recognition.onresult = (event) => {
|
|
|
|
|
let interim = '';
|
|
|
|
|
for (let i = 0; i < event.results.length; i++) {
|
|
|
|
|
const result = event.results[i];
|
|
|
|
|
const text = result[0].transcript;
|
|
|
|
|
if (result.isFinal) {
|
|
|
|
|
if (!confirmedRef.current[i]) confirmedRef.current[i] = text;
|
|
|
|
|
} else {
|
|
|
|
|
interim += text;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
setTranscript(confirmedRef.current.filter(Boolean).join(' '));
|
|
|
|
|
setInterimTranscript(interim);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
recognition.onerror = (event) => {
|
|
|
|
|
if (event.error === 'no-speech' || event.error === 'aborted') return;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
recognition.onend = () => {
|
|
|
|
|
// 녹음 중이면 자동 재시작
|
|
|
|
|
if (recognitionRef.current) {
|
|
|
|
|
try { recognitionRef.current.start(); } catch (e) {}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
recognition.start();
|
|
|
|
|
recognitionRef.current = recognition;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const stopSpeechRecognition = () => {
|
|
|
|
|
if (recognitionRef.current) {
|
|
|
|
|
const ref = recognitionRef.current;
|
|
|
|
|
recognitionRef.current = null; // onend에서 재시작 방지
|
|
|
|
|
try { ref.stop(); } catch (e) {}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const startRecording = async () => {
|
|
|
|
|
try {
|
|
|
|
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
|
|
|
@@ -183,8 +239,11 @@ function RecorderWidget({ onDone }) {
|
|
|
|
|
|
|
|
|
|
setPhase('recording');
|
|
|
|
|
setSeconds(0);
|
|
|
|
|
setTranscript('');
|
|
|
|
|
setInterimTranscript('');
|
|
|
|
|
timerRef.current = setInterval(() => setSeconds(s => s + 1), 1000);
|
|
|
|
|
drawWaveform();
|
|
|
|
|
startSpeechRecognition();
|
|
|
|
|
} catch {
|
|
|
|
|
alert('마이크 접근 권한이 필요합니다.\n브라우저 주소창의 자물쇠 아이콘에서 마이크를 허용해주세요.');
|
|
|
|
|
}
|
|
|
|
|
@@ -195,6 +254,7 @@ function RecorderWidget({ onDone }) {
|
|
|
|
|
mediaRef.current.pause();
|
|
|
|
|
clearInterval(timerRef.current);
|
|
|
|
|
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
|
|
|
|
stopSpeechRecognition();
|
|
|
|
|
setPhase('paused');
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
@@ -204,6 +264,7 @@ function RecorderWidget({ onDone }) {
|
|
|
|
|
mediaRef.current.resume();
|
|
|
|
|
timerRef.current = setInterval(() => setSeconds(s => s + 1), 1000);
|
|
|
|
|
drawWaveform();
|
|
|
|
|
startSpeechRecognition();
|
|
|
|
|
setPhase('recording');
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
@@ -213,6 +274,7 @@ function RecorderWidget({ onDone }) {
|
|
|
|
|
|
|
|
|
|
clearInterval(timerRef.current);
|
|
|
|
|
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
|
|
|
|
stopSpeechRecognition();
|
|
|
|
|
|
|
|
|
|
const duration = seconds;
|
|
|
|
|
|
|
|
|
|
@@ -227,7 +289,9 @@ function RecorderWidget({ onDone }) {
|
|
|
|
|
const iv = setInterval(() => setSaveProgress(p => Math.min(p + Math.random()*12, 90)), 300);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const create = await http.post(API, { title: title || '무제 음성녹음' });
|
|
|
|
|
const storeData = { title: title || '무제 음성녹음' };
|
|
|
|
|
if (selectedTemplateId) storeData.interview_template_id = parseInt(selectedTemplateId);
|
|
|
|
|
const create = await http.post(API, storeData);
|
|
|
|
|
if (!create.success) throw new Error(create.message);
|
|
|
|
|
|
|
|
|
|
setSaveProgress(40);
|
|
|
|
|
@@ -247,6 +311,9 @@ function RecorderWidget({ onDone }) {
|
|
|
|
|
setSeconds(0);
|
|
|
|
|
setTitle('');
|
|
|
|
|
setSaveProgress(0);
|
|
|
|
|
setTranscript('');
|
|
|
|
|
setInterimTranscript('');
|
|
|
|
|
setSelectedTemplateId('');
|
|
|
|
|
onDone(create.data.id);
|
|
|
|
|
}, 600);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
@@ -266,6 +333,7 @@ function RecorderWidget({ onDone }) {
|
|
|
|
|
const cancelRecording = () => {
|
|
|
|
|
clearInterval(timerRef.current);
|
|
|
|
|
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
|
|
|
|
stopSpeechRecognition();
|
|
|
|
|
if (mediaRef.current && mediaRef.current.state !== 'inactive') {
|
|
|
|
|
mediaRef.current.onstop = null;
|
|
|
|
|
mediaRef.current.ondataavailable = null;
|
|
|
|
|
@@ -274,6 +342,8 @@ function RecorderWidget({ onDone }) {
|
|
|
|
|
cleanup();
|
|
|
|
|
setPhase('idle');
|
|
|
|
|
setSeconds(0);
|
|
|
|
|
setTranscript('');
|
|
|
|
|
setInterimTranscript('');
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const cleanup = () => {
|
|
|
|
|
@@ -281,7 +351,7 @@ function RecorderWidget({ onDone }) {
|
|
|
|
|
audioCtxRef.current?.close().catch(()=>{});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
useEffect(() => () => { cleanup(); clearInterval(timerRef.current); if(rafRef.current) cancelAnimationFrame(rafRef.current); }, []);
|
|
|
|
|
useEffect(() => () => { stopSpeechRecognition(); cleanup(); clearInterval(timerRef.current); if(rafRef.current) cancelAnimationFrame(rafRef.current); }, []);
|
|
|
|
|
|
|
|
|
|
const isActive = phase === 'recording' || phase === 'paused';
|
|
|
|
|
|
|
|
|
|
@@ -329,11 +399,26 @@ function RecorderWidget({ onDone }) {
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="px-5 py-4 space-y-4">
|
|
|
|
|
{/* 제목 입력 */}
|
|
|
|
|
{/* 제목 + 카테고리 입력 */}
|
|
|
|
|
{!isActive && phase !== 'saving' && (
|
|
|
|
|
<input type="text" value={title} onChange={e => setTitle(e.target.value)}
|
|
|
|
|
placeholder="녹음 제목을 입력하세요 (선택)"
|
|
|
|
|
className="w-full text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-purple-300 focus:border-purple-400 transition" />
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<input type="text" value={title} onChange={e => setTitle(e.target.value)}
|
|
|
|
|
placeholder="녹음 제목을 입력하세요 (선택)"
|
|
|
|
|
className="w-full text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-purple-300 focus:border-purple-400 transition" />
|
|
|
|
|
{categories && categories.length > 0 && (
|
|
|
|
|
<select value={selectedTemplateId} onChange={e => setSelectedTemplateId(e.target.value)}
|
|
|
|
|
className="w-full text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-purple-300 focus:border-purple-400 transition text-gray-600 bg-white">
|
|
|
|
|
<option value="">시나리오 템플릿 선택 (선택)</option>
|
|
|
|
|
{categories.map(cat => (
|
|
|
|
|
<optgroup key={cat.id} label={cat.name}>
|
|
|
|
|
{(cat.templates || []).map(tpl => (
|
|
|
|
|
<option key={tpl.id} value={tpl.id}>{tpl.title || tpl.name}</option>
|
|
|
|
|
))}
|
|
|
|
|
</optgroup>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Canvas 파형 */}
|
|
|
|
|
@@ -352,6 +437,28 @@ className="w-full text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outl
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 실시간 음성인식 텍스트 */}
|
|
|
|
|
{isActive && (
|
|
|
|
|
<div className="bg-gray-50 border border-gray-200 rounded-lg p-3 min-h-[80px] max-h-[200px] overflow-y-auto">
|
|
|
|
|
<div className="flex items-center justify-between mb-1.5">
|
|
|
|
|
<span className="text-xs font-medium text-gray-500 flex items-center gap-1">
|
|
|
|
|
<Icon name="message-square" className="w-3 h-3" />
|
|
|
|
|
실시간 음성인식
|
|
|
|
|
</span>
|
|
|
|
|
<span className="text-xs text-gray-400">{(transcript + ' ' + interimTranscript).trim().length}자</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="text-sm text-gray-700 leading-relaxed whitespace-pre-wrap">
|
|
|
|
|
{transcript}
|
|
|
|
|
{interimTranscript && (
|
|
|
|
|
<span className="text-purple-500 italic">{transcript ? ' ' : ''}{interimTranscript}</span>
|
|
|
|
|
)}
|
|
|
|
|
{!transcript && !interimTranscript && (
|
|
|
|
|
<span className="text-gray-400 italic">말씀하시면 여기에 텍스트가 표시됩니다...</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 타이머 (녹음중에만) */}
|
|
|
|
|
{isActive && (
|
|
|
|
|
<div className="text-center">
|
|
|
|
|
@@ -417,9 +524,10 @@ className="flex items-center justify-center w-11 h-11 rounded-full bg-gray-100 h
|
|
|
|
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
|
|
|
// FileUploadWidget
|
|
|
|
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
|
|
|
function FileUploadWidget({ onDone, onCancel }) {
|
|
|
|
|
function FileUploadWidget({ onDone, onCancel, categories }) {
|
|
|
|
|
const [title, setTitle] = useState('');
|
|
|
|
|
const [file, setFile] = useState(null);
|
|
|
|
|
const [selectedTemplateId, setSelectedTemplateId] = useState('');
|
|
|
|
|
const [dragOver, setDragOver] = useState(false);
|
|
|
|
|
const [uploading, setUploading] = useState(false);
|
|
|
|
|
const [progress, setProgress] = useState(0);
|
|
|
|
|
@@ -436,6 +544,7 @@ function FileUploadWidget({ onDone, onCancel }) {
|
|
|
|
|
const fd = new FormData();
|
|
|
|
|
fd.append('audio_file', file);
|
|
|
|
|
fd.append('title', title || file.name.replace(/\.[^.]+$/, ''));
|
|
|
|
|
if (selectedTemplateId) fd.append('interview_template_id', selectedTemplateId);
|
|
|
|
|
const res = await http.upload(`${API}/upload`, fd);
|
|
|
|
|
clearInterval(iv);
|
|
|
|
|
setProgress(100);
|
|
|
|
|
@@ -478,6 +587,20 @@ function FileUploadWidget({ onDone, onCancel }) {
|
|
|
|
|
placeholder="제목 (비워두면 파일명 사용)"
|
|
|
|
|
className="w-full text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-300 focus:border-blue-400 transition" />
|
|
|
|
|
|
|
|
|
|
{categories && categories.length > 0 && (
|
|
|
|
|
<select value={selectedTemplateId} onChange={e => setSelectedTemplateId(e.target.value)}
|
|
|
|
|
className="w-full text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-300 focus:border-blue-400 transition text-gray-600 bg-white">
|
|
|
|
|
<option value="">시나리오 템플릿 선택 (선택)</option>
|
|
|
|
|
{categories.map(cat => (
|
|
|
|
|
<optgroup key={cat.id} label={cat.name}>
|
|
|
|
|
{(cat.templates || []).map(tpl => (
|
|
|
|
|
<option key={tpl.id} value={tpl.id}>{tpl.title || tpl.name}</option>
|
|
|
|
|
))}
|
|
|
|
|
</optgroup>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<div className={`drop-zone rounded-lg p-8 text-center cursor-pointer ${dragOver?'active':''}`}
|
|
|
|
|
onDragOver={e=>{e.preventDefault();setDragOver(true)}}
|
|
|
|
|
onDragLeave={()=>setDragOver(false)}
|
|
|
|
|
@@ -696,9 +819,17 @@ function App() {
|
|
|
|
|
const [mode, setMode] = useState('list'); // list | record | upload
|
|
|
|
|
const [detail, setDetail] = useState(null);
|
|
|
|
|
const [toasts, setToasts] = useState([]);
|
|
|
|
|
const [categories, setCategories] = useState([]);
|
|
|
|
|
const pollingRef = useRef({});
|
|
|
|
|
const toastId = useRef(0);
|
|
|
|
|
|
|
|
|
|
// 카테고리 목록 로드
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
http.get(`${API}/categories`).then(r => {
|
|
|
|
|
if (r.success) setCategories(r.data || []);
|
|
|
|
|
}).catch(() => {});
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const toast = (msg, type='success') => {
|
|
|
|
|
const id = ++toastId.current;
|
|
|
|
|
setToasts(ts => [...ts, { id, msg, type }]);
|
|
|
|
|
@@ -816,12 +947,12 @@ className={`inline-flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-m
|
|
|
|
|
{/* 녹음/업로드 위젯 */}
|
|
|
|
|
{mode === 'record' && (
|
|
|
|
|
<div className="mb-5">
|
|
|
|
|
<RecorderWidget onDone={handleRecordDone} />
|
|
|
|
|
<RecorderWidget onDone={handleRecordDone} categories={categories} />
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{mode === 'upload' && (
|
|
|
|
|
<div className="mb-5">
|
|
|
|
|
<FileUploadWidget onDone={handleUploadDone} onCancel={()=>setMode('list')} />
|
|
|
|
|
<FileUploadWidget onDone={handleUploadDone} onCancel={()=>setMode('list')} categories={categories} />
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|