diff --git a/app/Http/Controllers/System/AiVoiceRecordingController.php b/app/Http/Controllers/System/AiVoiceRecordingController.php index de7efedd..991ffb07 100644 --- a/app/Http/Controllers/System/AiVoiceRecordingController.php +++ b/app/Http/Controllers/System/AiVoiceRecordingController.php @@ -4,6 +4,7 @@ use App\Http\Controllers\Controller; use App\Models\AiVoiceRecording; +use App\Models\Interview\InterviewCategory; use App\Services\AiVoiceRecordingService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -42,6 +43,24 @@ public function list(Request $request): JsonResponse ]); } + /** + * 카테고리 + 템플릿 목록 + */ + public function categories(): JsonResponse + { + $categories = InterviewCategory::with(['templates' => function ($q) { + $q->where('is_active', true)->orderBy('sort_order'); + }]) + ->where('is_active', true) + ->orderBy('sort_order') + ->get(['id', 'name', 'description']); + + return response()->json([ + 'success' => true, + 'data' => $categories, + ]); + } + /** * 새 녹음 생성 */ @@ -49,6 +68,7 @@ public function store(Request $request): JsonResponse { $validated = $request->validate([ 'title' => 'nullable|string|max:200', + 'interview_template_id' => 'nullable|integer|exists:interview_templates,id', ]); $recording = $this->service->create($validated); @@ -107,10 +127,12 @@ public function uploadFile(Request $request): JsonResponse $validated = $request->validate([ 'audio_file' => 'required|file|mimes:webm,wav,mp3,ogg,m4a,mp4|max:102400', 'title' => 'nullable|string|max:200', + 'interview_template_id' => 'nullable|integer|exists:interview_templates,id', ]); $recording = $this->service->create([ 'title' => $validated['title'] ?? '업로드된 음성녹음', + 'interview_template_id' => $validated['interview_template_id'] ?? null, ]); $result = $this->service->processUploadedFile( diff --git a/app/Services/AiVoiceRecordingService.php b/app/Services/AiVoiceRecordingService.php index 99a6b73d..4e66425c 100644 --- a/app/Services/AiVoiceRecordingService.php +++ b/app/Services/AiVoiceRecordingService.php @@ -61,6 +61,7 @@ public function create(array $data): AiVoiceRecording 'tenant_id' => session('selected_tenant_id'), 'user_id' => Auth::id(), 'title' => $data['title'] ?? '무제 음성녹음', + 'interview_template_id' => $data['interview_template_id'] ?? null, 'status' => AiVoiceRecording::STATUS_PENDING, 'file_expiry_date' => now()->addDays(7), ]); diff --git a/resources/views/system/ai-voice-recording/index.blade.php b/resources/views/system/ai-voice-recording/index.blade.php index 91e910e0..58d2c453 100644 --- a/resources/views/system/ai-voice-recording/index.blade.php +++ b/resources/views/system/ai-voice-recording/index.blade.php @@ -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 }) {
- {/* 제목 입력 */} + {/* 제목 + 카테고리 입력 */} {!isActive && phase !== 'saving' && ( - 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" /> +
+ 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 && ( + + )} +
)} {/* Canvas 파형 */} @@ -352,6 +437,28 @@ className="w-full text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outl )}
+ {/* 실시간 음성인식 텍스트 */} + {isActive && ( +
+
+ + + 실시간 음성인식 + + {(transcript + ' ' + interimTranscript).trim().length}자 +
+
+ {transcript} + {interimTranscript && ( + {transcript ? ' ' : ''}{interimTranscript} + )} + {!transcript && !interimTranscript && ( + 말씀하시면 여기에 텍스트가 표시됩니다... + )} +
+
+ )} + {/* 타이머 (녹음중에만) */} {isActive && (
@@ -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 && ( + + )} +
{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' && (
- +
)} {mode === 'upload' && (
- setMode('list')} /> + setMode('list')} categories={categories} />
)} diff --git a/routes/web.php b/routes/web.php index 06a71d36..9c8fd95b 100644 --- a/routes/web.php +++ b/routes/web.php @@ -416,6 +416,7 @@ Route::prefix('system/ai-voice-recording')->name('system.ai-voice-recording.')->group(function () { Route::get('/', [AiVoiceRecordingController::class, 'index'])->name('index'); Route::get('/list', [AiVoiceRecordingController::class, 'list'])->name('list'); + Route::get('/categories', [AiVoiceRecordingController::class, 'categories'])->name('categories'); Route::post('/', [AiVoiceRecordingController::class, 'store'])->name('store'); Route::post('/upload', [AiVoiceRecordingController::class, 'uploadFile'])->name('upload'); Route::get('/{id}', [AiVoiceRecordingController::class, 'show'])->name('show');