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 }) {