feat:AI 음성녹음 실시간 음성인식 및 카테고리 선택기 추가

- Web Speech API로 녹음 중 실시간 텍스트 표시
- 인터뷰 카테고리/템플릿 선택 드롭다운 추가
- 녹음/파일업로드 시 interview_template_id 전달

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
김보곤
2026-02-07 13:15:46 +09:00
parent 031bcf8a4c
commit af344bcc60
4 changed files with 165 additions and 10 deletions

View File

@@ -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(

View File

@@ -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),
]);

View File

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

View File

@@ -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');