- 프론트엔드: Web Audio API 전처리 파이프라인 (GainNode + DynamicsCompressor + AnalyserNode) - 프론트엔드: VU 미터 실시간 레벨 표시 + 마이크 감도 슬라이더 (0.5x~3.0x) - 프론트엔드: getUserMedia constraints 강화 + MediaRecorder 128kbps Opus - 백엔드: Google STT V2 API + Chirp 2 모델 batchRecognize 메서드 추가 - 백엔드: V2→V1 자동 폴백 래퍼 (speechToTextWithDiarizationAuto) - 백엔드: Speech Adaptation 도메인 용어 힌트 (블라인드/스크린 등 22개) - 백엔드: V2 SentencePiece 토큰 자동 감지 분기 처리 - 설정: config/services.php에 google.location 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1316 lines
66 KiB
PHP
1316 lines
66 KiB
PHP
@extends('layouts.app')
|
|
|
|
@section('title', '회의록 작성')
|
|
|
|
@section('content')
|
|
<div id="root"></div>
|
|
@endsection
|
|
|
|
@push('scripts')
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<script src="https://unpkg.com/react@18/umd/react.development.js"></script>
|
|
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
|
|
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
|
<script type="text/babel">
|
|
@verbatim
|
|
const { useState, useEffect, useCallback, useRef, useMemo } = React;
|
|
|
|
const API = {
|
|
list: '/juil/meeting-minutes/list',
|
|
store: '/juil/meeting-minutes',
|
|
show: (id) => `/juil/meeting-minutes/${id}`,
|
|
update: (id) => `/juil/meeting-minutes/${id}`,
|
|
destroy: (id) => `/juil/meeting-minutes/${id}`,
|
|
saveSegments: (id) => `/juil/meeting-minutes/${id}/segments`,
|
|
uploadAudio: (id) => `/juil/meeting-minutes/${id}/upload-audio`,
|
|
summarize: (id) => `/juil/meeting-minutes/${id}/summarize`,
|
|
downloadAudio: (id) => `/juil/meeting-minutes/${id}/download-audio`,
|
|
diarize: (id) => `/juil/meeting-minutes/${id}/diarize`,
|
|
logSttUsage: '/juil/meeting-minutes/log-stt-usage',
|
|
};
|
|
|
|
const CSRF_TOKEN = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
|
|
|
const SPEAKER_COLORS = [
|
|
{ name: '화자 1', bg: 'bg-blue-50', border: 'border-blue-200', text: 'text-blue-700', badge: 'bg-blue-100 text-blue-800', dot: 'bg-blue-500' },
|
|
{ name: '화자 2', bg: 'bg-green-50', border: 'border-green-200', text: 'text-green-700', badge: 'bg-green-100 text-green-800', dot: 'bg-green-500' },
|
|
{ name: '화자 3', bg: 'bg-purple-50', border: 'border-purple-200', text: 'text-purple-700', badge: 'bg-purple-100 text-purple-800', dot: 'bg-purple-500' },
|
|
{ name: '화자 4', bg: 'bg-orange-50', border: 'border-orange-200', text: 'text-orange-700', badge: 'bg-orange-100 text-orange-800', dot: 'bg-orange-500' },
|
|
];
|
|
|
|
const STATUS_LABELS = {
|
|
DRAFT: { label: '초안', color: 'bg-gray-100 text-gray-800' },
|
|
RECORDING: { label: '녹음중', color: 'bg-red-100 text-red-800' },
|
|
PROCESSING: { label: '처리중', color: 'bg-yellow-100 text-yellow-800' },
|
|
COMPLETED: { label: '완료', color: 'bg-green-100 text-green-800' },
|
|
FAILED: { label: '실패', color: 'bg-red-100 text-red-800' },
|
|
};
|
|
|
|
const LANGUAGES = [
|
|
{ code: 'ko-KR', label: '한국어' },
|
|
{ code: 'en-US', label: 'English' },
|
|
{ code: 'ja-JP', label: '日本語' },
|
|
{ code: 'zh-CN', label: '中文' },
|
|
];
|
|
|
|
async function apiFetch(url, options = {}) {
|
|
const { headers: optHeaders, ...restOptions } = options;
|
|
const res = await fetch(url, {
|
|
...restOptions,
|
|
headers: {
|
|
'X-CSRF-TOKEN': CSRF_TOKEN,
|
|
'Accept': 'application/json',
|
|
...optHeaders,
|
|
},
|
|
});
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({ message: '요청 처리 중 오류가 발생했습니다.' }));
|
|
throw new Error(err.message || `HTTP ${res.status}`);
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
function formatDuration(seconds) {
|
|
const h = Math.floor(seconds / 3600);
|
|
const m = Math.floor((seconds % 3600) / 60);
|
|
const s = seconds % 60;
|
|
if (h > 0) return `${h}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
|
|
return `${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
|
|
}
|
|
|
|
function formatMs(ms) {
|
|
const totalSec = Math.floor(ms / 1000);
|
|
const m = Math.floor(totalSec / 60);
|
|
const s = totalSec % 60;
|
|
return `${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
|
|
}
|
|
|
|
// ========== Toast ==========
|
|
function ToastNotification({ toast, onClose }) {
|
|
useEffect(() => {
|
|
if (toast) {
|
|
const t = setTimeout(onClose, 3000);
|
|
return () => clearTimeout(t);
|
|
}
|
|
}, [toast]);
|
|
if (!toast) return null;
|
|
const colors = toast.type === 'error' ? 'bg-red-500' : toast.type === 'warning' ? 'bg-yellow-500' : 'bg-green-500';
|
|
return (
|
|
<div className={`fixed top-4 right-4 z-50 ${colors} text-white px-4 py-3 rounded-lg shadow-lg flex items-center gap-2 max-w-sm`}>
|
|
<span className="flex-1 text-sm">{toast.message}</span>
|
|
<button onClick={onClose} className="text-white/80 hover:text-white">×</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ========== AlertModal ==========
|
|
function AlertModal({ show, title, message, icon, onClose }) {
|
|
if (!show) return null;
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
|
|
<div className="bg-white rounded-xl shadow-2xl max-w-sm w-full mx-4 p-6" onClick={(e) => e.stopPropagation()}>
|
|
<div className="flex flex-col items-center text-center">
|
|
{icon === 'warning' && (
|
|
<div className="w-14 h-14 rounded-full bg-yellow-100 flex items-center justify-center mb-4">
|
|
<svg className="w-7 h-7 text-yellow-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.072 16.5c-.77.833.192 2.5 1.732 2.5z" /></svg>
|
|
</div>
|
|
)}
|
|
{icon === 'info' && (
|
|
<div className="w-14 h-14 rounded-full bg-blue-100 flex items-center justify-center mb-4">
|
|
<svg className="w-7 h-7 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
|
</div>
|
|
)}
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">{title}</h3>
|
|
<p className="text-sm text-gray-500 leading-relaxed">{message}</p>
|
|
<button onClick={onClose} className="mt-5 w-full bg-blue-600 text-white py-2.5 rounded-lg hover:bg-blue-700 transition text-sm font-medium">확인</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ========== MeetingCard ==========
|
|
function MeetingCard({ meeting, onClick }) {
|
|
const st = STATUS_LABELS[meeting.status] || STATUS_LABELS.DRAFT;
|
|
const date = meeting.meeting_date ? new Date(meeting.meeting_date).toLocaleDateString('ko-KR') : '';
|
|
const participants = meeting.participants || [];
|
|
|
|
return (
|
|
<div onClick={() => onClick(meeting.id)} className="bg-white rounded-lg border border-gray-200 p-4 hover:shadow-md hover:border-blue-300 transition-all cursor-pointer">
|
|
<div className="flex items-start justify-between mb-2">
|
|
<h3 className="font-medium text-gray-900 truncate flex-1">{meeting.title}</h3>
|
|
<span className={`ml-2 px-2 py-0.5 rounded-full text-xs font-medium ${st.color}`}>{st.label}</span>
|
|
</div>
|
|
<div className="text-sm text-gray-500 space-y-1">
|
|
<div className="flex items-center gap-2">
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
|
|
<span>{date}</span>
|
|
{meeting.duration_seconds > 0 && <span className="text-gray-400">| {formatDuration(meeting.duration_seconds)}</span>}
|
|
</div>
|
|
{participants.length > 0 && (
|
|
<div className="flex flex-wrap gap-1 mt-1">
|
|
{participants.slice(0, 3).map((p, i) => (
|
|
<span key={i} className="inline-flex items-center px-2 py-0.5 rounded-full text-xs bg-gray-100 text-gray-600">{p}</span>
|
|
))}
|
|
{participants.length > 3 && <span className="text-xs text-gray-400">+{participants.length - 3}</span>}
|
|
</div>
|
|
)}
|
|
{meeting.folder && <div className="text-xs text-gray-400 mt-1">📁 {meeting.folder}</div>}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ========== MeetingList ==========
|
|
function MeetingList({ onSelect, onNew, showToast }) {
|
|
const [meetings, setMeetings] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [search, setSearch] = useState('');
|
|
const [pagination, setPagination] = useState(null);
|
|
|
|
const loadMeetings = useCallback(async (page = 1) => {
|
|
setLoading(true);
|
|
try {
|
|
const params = new URLSearchParams({ per_page: 12, page });
|
|
if (search) params.set('search', search);
|
|
const res = await apiFetch(`${API.list}?${params}`);
|
|
setMeetings(res.data.data || []);
|
|
setPagination({ current_page: res.data.current_page, last_page: res.data.last_page, total: res.data.total });
|
|
} catch (e) {
|
|
showToast(e.message, 'error');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [search]);
|
|
|
|
useEffect(() => { loadMeetings(); }, [loadMeetings]);
|
|
|
|
const handleCreate = async () => {
|
|
try {
|
|
const res = await apiFetch(API.store, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ title: '무제 회의록', meeting_date: new Date().toISOString().split('T')[0] }),
|
|
});
|
|
showToast('새 회의록이 생성되었습니다.');
|
|
onSelect(res.data.id);
|
|
} catch (e) {
|
|
showToast(e.message, 'error');
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="p-4">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-gray-900">회의록</h1>
|
|
<p className="text-sm text-gray-500 mt-1">AI 음성 인식 + 자동 요약 회의록</p>
|
|
</div>
|
|
<button onClick={handleCreate} className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition flex items-center gap-2 text-sm font-medium">
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" /></svg>
|
|
새 회의록
|
|
</button>
|
|
</div>
|
|
|
|
<div className="mb-4">
|
|
<input type="text" value={search} onChange={(e) => setSearch(e.target.value)} placeholder="회의록 검색..." className="w-full max-w-md px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm" />
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-20">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
|
</div>
|
|
) : meetings.length === 0 ? (
|
|
<div className="text-center py-20 text-gray-500">
|
|
<svg className="w-16 h-16 mx-auto mb-4 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" /></svg>
|
|
<p className="text-lg font-medium">회의록이 없습니다</p>
|
|
<p className="text-sm mt-1">새 회의록을 만들어 음성 녹음을 시작하세요.</p>
|
|
<button onClick={handleCreate} className="mt-4 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 text-sm">새 회의록 만들기</button>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{meetings.map(m => <MeetingCard key={m.id} meeting={m} onClick={onSelect} />)}
|
|
</div>
|
|
{pagination && pagination.last_page > 1 && (
|
|
<div className="flex justify-center gap-2 mt-6">
|
|
{Array.from({ length: pagination.last_page }, (_, i) => i + 1).map(p => (
|
|
<button key={p} onClick={() => loadMeetings(p)} className={`px-3 py-1 rounded text-sm ${p === pagination.current_page ? 'bg-blue-600 text-white' : 'bg-white border text-gray-700 hover:bg-gray-50'}`}>{p}</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ========== MeetingDetail ==========
|
|
function MeetingDetail({ meetingId, onBack, showToast }) {
|
|
const [meeting, setMeeting] = useState(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [activeTab, setActiveTab] = useState('conversation');
|
|
const [showSummary, setShowSummary] = useState(true);
|
|
|
|
// 녹음 상태
|
|
const [isRecording, setIsRecording] = useState(false);
|
|
const [recordingTime, setRecordingTime] = useState(0);
|
|
const [localSegments, setLocalSegments] = useState([]);
|
|
const [interimText, setInterimText] = useState('');
|
|
const [currentSpeakerIdx, setCurrentSpeakerIdx] = useState(0);
|
|
const [speakers, setSpeakers] = useState([{ name: '화자 1' }, { name: '화자 2' }]);
|
|
const [sttLanguage, setSttLanguage] = useState('ko-KR');
|
|
|
|
// 편집 상태
|
|
const [editingTitle, setEditingTitle] = useState(false);
|
|
const [titleValue, setTitleValue] = useState('');
|
|
const [saving, setSaving] = useState(false);
|
|
const [summarizing, setSummarizing] = useState(false);
|
|
const [diarizing, setDiarizing] = useState(false);
|
|
const [alertModal, setAlertModal] = useState(null);
|
|
|
|
// 마이크 감도
|
|
const [micGain, setMicGain] = useState(1.5);
|
|
|
|
// 세그먼트 편집 상태
|
|
const [editingSegments, setEditingSegments] = useState(false);
|
|
const [editBackup, setEditBackup] = useState([]);
|
|
|
|
// refs
|
|
const mediaRecorderRef = useRef(null);
|
|
const audioChunksRef = useRef([]);
|
|
const recognitionRef = useRef(null);
|
|
const streamRef = useRef(null);
|
|
const timerRef = useRef(null);
|
|
const transcriptRef = useRef(null);
|
|
const startTimeRef = useRef(null);
|
|
const audioContextRef = useRef(null);
|
|
const analyserRef = useRef(null);
|
|
const gainNodeRef = useRef(null);
|
|
|
|
const loadMeeting = useCallback(async () => {
|
|
try {
|
|
const res = await apiFetch(API.show(meetingId));
|
|
const data = res.data;
|
|
setMeeting(data);
|
|
setTitleValue(data.title);
|
|
setSttLanguage(data.stt_language || 'ko-KR');
|
|
if (data.segments && data.segments.length > 0) {
|
|
setLocalSegments(data.segments.map(s => ({
|
|
speaker_name: s.speaker_name,
|
|
text: s.text,
|
|
start_time_ms: s.start_time_ms,
|
|
end_time_ms: s.end_time_ms,
|
|
is_final: true,
|
|
})));
|
|
// 화자 목록 복원
|
|
const uniqueSpeakers = [...new Set(data.segments.map(s => s.speaker_name))];
|
|
if (uniqueSpeakers.length > 0) {
|
|
setSpeakers(uniqueSpeakers.map(n => ({ name: n })));
|
|
}
|
|
}
|
|
} catch (e) {
|
|
showToast(e.message, 'error');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [meetingId]);
|
|
|
|
useEffect(() => { loadMeeting(); }, [loadMeeting]);
|
|
|
|
// 타이머
|
|
useEffect(() => {
|
|
if (isRecording) {
|
|
timerRef.current = setInterval(() => setRecordingTime(prev => prev + 1), 1000);
|
|
} else if (timerRef.current) {
|
|
clearInterval(timerRef.current);
|
|
}
|
|
return () => { if (timerRef.current) clearInterval(timerRef.current); };
|
|
}, [isRecording]);
|
|
|
|
// 자동 스크롤
|
|
useEffect(() => {
|
|
if (transcriptRef.current) {
|
|
transcriptRef.current.scrollTop = transcriptRef.current.scrollHeight;
|
|
}
|
|
}, [localSegments, interimText]);
|
|
|
|
const currentSpeaker = speakers[currentSpeakerIdx] || speakers[0];
|
|
const speakerColor = SPEAKER_COLORS[currentSpeakerIdx % SPEAKER_COLORS.length];
|
|
|
|
const getSpeakerColor = (name) => {
|
|
const idx = speakers.findIndex(s => s.name === name);
|
|
return SPEAKER_COLORS[idx >= 0 ? idx % SPEAKER_COLORS.length : 0];
|
|
};
|
|
|
|
// ===== 녹음 시작 =====
|
|
const startRecording = async () => {
|
|
try {
|
|
const stream = await navigator.mediaDevices.getUserMedia({
|
|
audio: {
|
|
echoCancellation: true,
|
|
noiseSuppression: true,
|
|
autoGainControl: true,
|
|
channelCount: 1,
|
|
sampleRate: 48000,
|
|
}
|
|
});
|
|
streamRef.current = stream;
|
|
|
|
// Web Audio API 전처리 체인
|
|
const audioCtx = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 48000 });
|
|
audioContextRef.current = audioCtx;
|
|
|
|
const source = audioCtx.createMediaStreamSource(stream);
|
|
|
|
// GainNode: 감도 조절 (기본 1.5x)
|
|
const gainNode = audioCtx.createGain();
|
|
gainNode.gain.value = micGain;
|
|
gainNodeRef.current = gainNode;
|
|
|
|
// DynamicsCompressor: 작은 소리 증폭 + 큰 소리 억제
|
|
const compressor = audioCtx.createDynamicsCompressor();
|
|
compressor.threshold.value = -50;
|
|
compressor.knee.value = 40;
|
|
compressor.ratio.value = 12;
|
|
compressor.attack.value = 0;
|
|
compressor.release.value = 0.25;
|
|
|
|
// AnalyserNode: VU 미터용
|
|
const analyser = audioCtx.createAnalyser();
|
|
analyser.fftSize = 2048;
|
|
analyserRef.current = analyser;
|
|
|
|
// MediaStreamDestination: 처리된 스트림
|
|
const destination = audioCtx.createMediaStreamDestination();
|
|
|
|
// 체인 연결: source → gain → compressor → analyser → destination
|
|
source.connect(gainNode);
|
|
gainNode.connect(compressor);
|
|
compressor.connect(analyser);
|
|
analyser.connect(destination);
|
|
|
|
const processedStream = destination.stream;
|
|
|
|
// MediaRecorder (처리된 스트림 사용)
|
|
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
|
|
? 'audio/webm;codecs=opus' : 'audio/webm';
|
|
const recorder = new MediaRecorder(processedStream, { mimeType, audioBitsPerSecond: 128000 });
|
|
audioChunksRef.current = [];
|
|
recorder.ondataavailable = (e) => { if (e.data.size > 0) audioChunksRef.current.push(e.data); };
|
|
recorder.start();
|
|
mediaRecorderRef.current = recorder;
|
|
|
|
// Web Speech API
|
|
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
|
if (SpeechRecognition) {
|
|
const recognition = new SpeechRecognition();
|
|
recognition.lang = sttLanguage;
|
|
recognition.continuous = true;
|
|
recognition.interimResults = true;
|
|
recognition.maxAlternatives = 1;
|
|
|
|
recognition.onresult = (event) => {
|
|
let currentInterim = '';
|
|
for (let i = event.resultIndex; i < event.results.length; i++) {
|
|
const text = event.results[i][0].transcript;
|
|
if (event.results[i].isFinal) {
|
|
const now = Date.now();
|
|
const startMs = startTimeRef.current ? now - startTimeRef.current : 0;
|
|
setLocalSegments(prev => [...prev, {
|
|
speaker_name: currentSpeaker.name,
|
|
text: text.trim(),
|
|
start_time_ms: startMs,
|
|
end_time_ms: null,
|
|
is_final: true,
|
|
}]);
|
|
currentInterim = '';
|
|
} else {
|
|
currentInterim = text;
|
|
}
|
|
}
|
|
setInterimText(currentInterim);
|
|
};
|
|
|
|
recognition.onerror = (event) => {
|
|
if (event.error === 'no-speech' || event.error === 'aborted') return;
|
|
console.warn('STT error:', event.error);
|
|
};
|
|
|
|
recognition.onend = () => {
|
|
if (isRecordingRef.current && recognitionRef.current) {
|
|
try { recognitionRef.current.start(); } catch (e) {}
|
|
}
|
|
};
|
|
|
|
recognition.start();
|
|
recognitionRef.current = recognition;
|
|
}
|
|
|
|
startTimeRef.current = Date.now();
|
|
setRecordingTime(0);
|
|
setIsRecording(true);
|
|
} catch (e) {
|
|
showToast('마이크 접근 권한이 필요합니다.', 'error');
|
|
}
|
|
};
|
|
|
|
// isRecording ref (onend에서 접근)
|
|
const isRecordingRef = useRef(false);
|
|
useEffect(() => { isRecordingRef.current = isRecording; }, [isRecording]);
|
|
|
|
// ===== 녹음 중지 =====
|
|
const stopRecording = async () => {
|
|
setIsRecording(false);
|
|
|
|
if (recognitionRef.current) {
|
|
recognitionRef.current.onend = null;
|
|
recognitionRef.current.stop();
|
|
recognitionRef.current = null;
|
|
}
|
|
|
|
if (streamRef.current) {
|
|
streamRef.current.getTracks().forEach(t => t.stop());
|
|
streamRef.current = null;
|
|
}
|
|
|
|
// AudioContext 정리
|
|
if (audioContextRef.current) {
|
|
audioContextRef.current.close().catch(() => {});
|
|
audioContextRef.current = null;
|
|
analyserRef.current = null;
|
|
gainNodeRef.current = null;
|
|
}
|
|
|
|
// MediaRecorder 중지 → blob 생성
|
|
const recorder = mediaRecorderRef.current;
|
|
if (recorder && recorder.state !== 'inactive') {
|
|
await new Promise(resolve => {
|
|
recorder.onstop = resolve;
|
|
recorder.stop();
|
|
});
|
|
}
|
|
|
|
const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
|
|
const duration = recordingTime;
|
|
|
|
setSaving(true);
|
|
try {
|
|
// 1. 세그먼트 임시 저장 (Web Speech API 결과)
|
|
const segmentsToSave = localSegments
|
|
.filter(s => s.is_final && s.text && s.text.trim())
|
|
.map((s, i) => ({
|
|
speaker_name: s.speaker_name || '화자 1',
|
|
text: s.text.trim(),
|
|
start_time_ms: Math.round(s.start_time_ms || 0),
|
|
end_time_ms: s.end_time_ms ? Math.round(s.end_time_ms) : null,
|
|
is_manual_speaker: true,
|
|
}));
|
|
|
|
if (segmentsToSave.length > 0) {
|
|
await apiFetch(API.saveSegments(meetingId), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ segments: segmentsToSave }),
|
|
});
|
|
}
|
|
|
|
// 2. 오디오 업로드
|
|
let audioUploaded = false;
|
|
if (audioBlob.size > 0 && duration > 0) {
|
|
const formData = new FormData();
|
|
formData.append('audio', audioBlob, 'recording.webm');
|
|
formData.append('duration_seconds', duration);
|
|
await apiFetch(API.uploadAudio(meetingId), {
|
|
method: 'POST',
|
|
body: formData,
|
|
});
|
|
audioUploaded = true;
|
|
}
|
|
|
|
// 3. STT 사용량 로깅
|
|
if (duration > 0) {
|
|
await apiFetch(API.logSttUsage, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ duration_seconds: duration }),
|
|
}).catch(() => {});
|
|
}
|
|
|
|
showToast('녹음이 저장되었습니다.');
|
|
setSaving(false);
|
|
|
|
// 4. 자동 화자 분리 (오디오 업로드 완료 시)
|
|
if (audioUploaded) {
|
|
setDiarizing(true);
|
|
try {
|
|
const diarizeRes = await apiFetch(API.diarize(meetingId), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ min_speakers: 2, max_speakers: Math.max(speakers.length, 2) }),
|
|
});
|
|
if (diarizeRes.data && diarizeRes.data.segments && diarizeRes.data.segments.length > 0) {
|
|
setLocalSegments(diarizeRes.data.segments.map(s => ({
|
|
speaker_name: s.speaker_name,
|
|
text: s.text,
|
|
start_time_ms: s.start_time_ms,
|
|
end_time_ms: s.end_time_ms,
|
|
is_final: true,
|
|
})));
|
|
const uniqueSpeakers = [...new Set(diarizeRes.data.segments.map(s => s.speaker_name))];
|
|
if (uniqueSpeakers.length > 0) {
|
|
setSpeakers(uniqueSpeakers.map(n => ({ name: n })));
|
|
}
|
|
}
|
|
showToast(`화자 분리 완료 (${diarizeRes.speaker_count || 1}명 감지)`);
|
|
} catch (e) {
|
|
showToast('자동 화자 분리 실패: ' + e.message, 'warning');
|
|
} finally {
|
|
setDiarizing(false);
|
|
}
|
|
}
|
|
|
|
// 5. 자동 AI 요약
|
|
setSummarizing(true);
|
|
try {
|
|
await apiFetch(API.summarize(meetingId), { method: 'POST' });
|
|
showToast('AI 요약이 완료되었습니다.');
|
|
} catch (e) {
|
|
showToast('AI 요약 실패: ' + e.message, 'warning');
|
|
} finally {
|
|
setSummarizing(false);
|
|
}
|
|
|
|
// 새로고침
|
|
await loadMeeting();
|
|
} catch (e) {
|
|
showToast('저장 실패: ' + e.message, 'error');
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
// ===== 화자 전환 =====
|
|
const switchSpeaker = (idx) => {
|
|
setCurrentSpeakerIdx(idx);
|
|
};
|
|
|
|
const addSpeaker = () => {
|
|
if (speakers.length >= 4) return;
|
|
const newIdx = speakers.length;
|
|
setSpeakers(prev => [...prev, { name: `화자 ${newIdx + 1}` }]);
|
|
};
|
|
|
|
// ===== 제목 인라인 편집 =====
|
|
const saveTitle = async () => {
|
|
if (!titleValue.trim()) return;
|
|
try {
|
|
await apiFetch(API.update(meetingId), {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ title: titleValue.trim() }),
|
|
});
|
|
setMeeting(prev => ({ ...prev, title: titleValue.trim() }));
|
|
setEditingTitle(false);
|
|
} catch (e) {
|
|
showToast(e.message, 'error');
|
|
}
|
|
};
|
|
|
|
// ===== 삭제 =====
|
|
const handleDelete = async () => {
|
|
if (!confirm('이 회의록을 삭제하시겠습니까?')) return;
|
|
try {
|
|
await apiFetch(API.destroy(meetingId), { method: 'DELETE' });
|
|
showToast('회의록이 삭제되었습니다.');
|
|
onBack();
|
|
} catch (e) {
|
|
showToast(e.message, 'error');
|
|
}
|
|
};
|
|
|
|
// ===== 노트 복사 =====
|
|
const copyNotes = () => {
|
|
const lines = localSegments.filter(s => s.is_final).map(s => `[${s.speaker_name}] ${s.text}`);
|
|
const text = lines.join('\n');
|
|
navigator.clipboard.writeText(text).then(() => showToast('클립보드에 복사되었습니다.'));
|
|
};
|
|
|
|
// ===== 세그먼트 편집 =====
|
|
const cleanSegmentText = (text) => text.replace(/[_\u2581]/g, ' ').replace(/\s{2,}/g, ' ').trim();
|
|
|
|
const startEditingSegments = () => {
|
|
setEditBackup(localSegments.map(s => ({ ...s })));
|
|
setLocalSegments(prev => prev.map(s => ({ ...s, text: cleanSegmentText(s.text) })));
|
|
setEditingSegments(true);
|
|
};
|
|
|
|
const cancelEditingSegments = () => {
|
|
setLocalSegments(editBackup);
|
|
setEditingSegments(false);
|
|
setEditBackup([]);
|
|
};
|
|
|
|
const saveEditedSegments = async () => {
|
|
setSaving(true);
|
|
try {
|
|
const segmentsToSave = localSegments
|
|
.filter(s => s.is_final && s.text && s.text.trim())
|
|
.map(s => ({
|
|
speaker_name: s.speaker_name || '화자 1',
|
|
text: s.text.trim(),
|
|
start_time_ms: Math.round(s.start_time_ms || 0),
|
|
end_time_ms: s.end_time_ms ? Math.round(s.end_time_ms) : null,
|
|
is_manual_speaker: true,
|
|
}));
|
|
await apiFetch(API.saveSegments(meetingId), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ segments: segmentsToSave }),
|
|
});
|
|
showToast('대화 기록이 저장되었습니다.');
|
|
setEditingSegments(false);
|
|
setEditBackup([]);
|
|
await loadMeeting();
|
|
} catch (e) {
|
|
showToast('저장 실패: ' + e.message, 'error');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleEditText = (segIdx, newText) => {
|
|
setLocalSegments(prev => prev.map((s, i) => i === segIdx ? { ...s, text: newText } : s));
|
|
};
|
|
|
|
const handleEditSpeaker = (segIdx, newSpeaker) => {
|
|
setLocalSegments(prev => prev.map((s, i) => i === segIdx ? { ...s, speaker_name: newSpeaker } : s));
|
|
};
|
|
|
|
const handleDeleteSegment = (segIdx) => {
|
|
setLocalSegments(prev => prev.filter((_, i) => i !== segIdx));
|
|
};
|
|
|
|
// ===== 수동 요약 =====
|
|
const handleSummarize = async () => {
|
|
const finalSegments = localSegments.filter(s => s.is_final && s.text && s.text.trim());
|
|
const hasContent = finalSegments.length > 0 || (meeting && meeting.full_transcript);
|
|
if (!hasContent) {
|
|
setAlertModal({
|
|
title: '대화 내용이 없습니다',
|
|
message: '요약을 실행하려면 먼저 녹음을 진행하여 대화 내용을 기록해주세요.',
|
|
icon: 'warning',
|
|
});
|
|
return;
|
|
}
|
|
setSummarizing(true);
|
|
try {
|
|
// 로컬 세그먼트가 있으면 먼저 서버에 저장 (full_transcript 업데이트)
|
|
if (finalSegments.length > 0) {
|
|
const segmentsToSave = finalSegments.map((s, i) => ({
|
|
speaker_name: s.speaker_name || '화자 1',
|
|
text: s.text.trim(),
|
|
start_time_ms: Math.round(s.start_time_ms || 0),
|
|
end_time_ms: s.end_time_ms ? Math.round(s.end_time_ms) : null,
|
|
is_manual_speaker: true,
|
|
}));
|
|
await apiFetch(API.saveSegments(meetingId), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ segments: segmentsToSave }),
|
|
});
|
|
}
|
|
await apiFetch(API.summarize(meetingId), { method: 'POST' });
|
|
showToast('AI 요약이 완료되었습니다.');
|
|
await loadMeeting();
|
|
} catch (e) {
|
|
showToast(e.message, 'error');
|
|
} finally {
|
|
setSummarizing(false);
|
|
}
|
|
};
|
|
|
|
// ===== 자동 화자 분리 =====
|
|
const handleDiarize = async () => {
|
|
if (!meeting || !meeting.audio_gcs_uri) {
|
|
setAlertModal({
|
|
title: '오디오 파일이 없습니다',
|
|
message: '자동 화자 분리를 실행하려면 먼저 녹음을 진행하여 오디오를 저장해주세요.',
|
|
icon: 'warning',
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (!confirm('자동 화자 분리를 실행하면 기존 대화 기록이 새로운 결과로 교체됩니다. 계속하시겠습니까?')) {
|
|
return;
|
|
}
|
|
|
|
setDiarizing(true);
|
|
try {
|
|
const res = await apiFetch(API.diarize(meetingId), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ min_speakers: 2, max_speakers: speakers.length || 4 }),
|
|
});
|
|
showToast(res.message || '자동 화자 분리가 완료되었습니다.');
|
|
await loadMeeting();
|
|
} catch (e) {
|
|
showToast('화자 분리 실패: ' + e.message, 'error');
|
|
} finally {
|
|
setDiarizing(false);
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return <div className="flex items-center justify-center h-96"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div></div>;
|
|
}
|
|
|
|
if (!meeting) {
|
|
return <div className="text-center py-20 text-gray-500">회의록을 찾을 수 없습니다.</div>;
|
|
}
|
|
|
|
const segments = localSegments
|
|
.map((s, i) => ({ ...s, __origIdx: i }))
|
|
.filter(s => s.is_final);
|
|
|
|
return (
|
|
<div className="flex flex-col" style={{height: 'calc(100vh - 112px)', margin: '-24px'}}>
|
|
{/* Alert Modal */}
|
|
<AlertModal show={!!alertModal} title={alertModal?.title} message={alertModal?.message} icon={alertModal?.icon} onClose={() => setAlertModal(null)} />
|
|
|
|
{/* Header */}
|
|
<div className="bg-white border-b px-4 py-3 flex-shrink-0">
|
|
<div className="flex items-center gap-3">
|
|
<button onClick={onBack} className="text-gray-500 hover:text-gray-700 p-1">
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /></svg>
|
|
</button>
|
|
{editingTitle ? (
|
|
<input value={titleValue} onChange={(e) => setTitleValue(e.target.value)} onBlur={saveTitle} onKeyDown={(e) => e.key === 'Enter' && saveTitle()} autoFocus className="text-lg font-bold border-b-2 border-blue-500 outline-none bg-transparent flex-1" />
|
|
) : (
|
|
<h1 onClick={() => setEditingTitle(true)} className="text-lg font-bold text-gray-900 cursor-pointer hover:text-blue-600 flex-1 truncate">{meeting.title}</h1>
|
|
)}
|
|
<div className="flex items-center gap-2 flex-shrink-0">
|
|
<button onClick={copyNotes} className="text-gray-500 hover:text-gray-700 text-sm flex items-center gap-1 px-2 py-1 rounded hover:bg-gray-100" title="노트 복사">
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" /></svg>
|
|
복사
|
|
</button>
|
|
{meeting.audio_file_path && (
|
|
<a href={API.downloadAudio(meetingId)} className="text-gray-500 hover:text-gray-700 text-sm flex items-center gap-1 px-2 py-1 rounded hover:bg-gray-100" title="음성 다운로드">
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /></svg>
|
|
</a>
|
|
)}
|
|
<button onClick={handleDelete} className="text-red-400 hover:text-red-600 p-1" title="삭제">
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-3 mt-2 text-sm text-gray-500 flex-wrap">
|
|
<span className="flex items-center gap-1">
|
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
|
|
{meeting.meeting_date ? new Date(meeting.meeting_date).toLocaleDateString('ko-KR') : ''}
|
|
</span>
|
|
{meeting.duration_seconds > 0 && (
|
|
<span className="flex items-center gap-1">
|
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
|
{formatDuration(meeting.duration_seconds)}
|
|
</span>
|
|
)}
|
|
{meeting.participants && meeting.participants.length > 0 && (
|
|
<div className="flex items-center gap-1">
|
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" /></svg>
|
|
{meeting.participants.join(', ')}
|
|
</div>
|
|
)}
|
|
{meeting.status && <span className={`px-2 py-0.5 rounded-full text-xs font-medium ${(STATUS_LABELS[meeting.status] || STATUS_LABELS.DRAFT).color}`}>{(STATUS_LABELS[meeting.status] || STATUS_LABELS.DRAFT).label}</span>}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tab Bar */}
|
|
<div className="bg-white border-b px-4 flex items-center gap-4 flex-shrink-0">
|
|
<button onClick={() => setActiveTab('conversation')} className={`py-2.5 text-sm font-medium border-b-2 transition ${activeTab === 'conversation' ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>대화 기록</button>
|
|
<button onClick={() => { if (editingSegments) cancelEditingSegments(); setActiveTab('script'); }} className={`py-2.5 text-sm font-medium border-b-2 transition ${activeTab === 'script' ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>스크립트</button>
|
|
<div className="flex-1"></div>
|
|
{activeTab === 'conversation' && !isRecording && segments.length > 0 && !editingSegments && (
|
|
<button onClick={startEditingSegments} className="py-2 px-3 text-sm font-medium text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded transition flex items-center gap-1">
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>
|
|
편집
|
|
</button>
|
|
)}
|
|
{editingSegments && (
|
|
<>
|
|
<button onClick={cancelEditingSegments} className="py-2 px-3 text-sm font-medium text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded transition">
|
|
취소
|
|
</button>
|
|
<button onClick={saveEditedSegments} disabled={saving} className="py-2 px-3 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded transition disabled:opacity-50 flex items-center gap-1">
|
|
{saving ? <div className="animate-spin rounded-full h-3 w-3 border-b-2 border-white"></div> : <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>}
|
|
저장
|
|
</button>
|
|
</>
|
|
)}
|
|
<button onClick={() => setShowSummary(!showSummary)} className={`py-2.5 text-sm font-medium flex items-center gap-1 ${showSummary ? 'text-blue-600' : 'text-gray-500'}`}>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" /></svg>
|
|
AI 요약
|
|
</button>
|
|
</div>
|
|
|
|
{/* Main Content */}
|
|
<div className="flex flex-1 overflow-hidden">
|
|
{/* Left: Transcript */}
|
|
<div className="flex-1 overflow-y-auto p-4" ref={transcriptRef}>
|
|
{activeTab === 'conversation' ? (
|
|
<ConversationView segments={segments} interimText={interimText} isRecording={isRecording} currentSpeaker={currentSpeaker} getSpeakerColor={getSpeakerColor} editing={editingSegments} onEditText={handleEditText} onEditSpeaker={handleEditSpeaker} onDeleteSegment={handleDeleteSegment} speakers={speakers} />
|
|
) : (
|
|
<ScriptView segments={segments} interimText={interimText} isRecording={isRecording} />
|
|
)}
|
|
</div>
|
|
|
|
{/* Right: Summary Panel */}
|
|
{showSummary && (
|
|
<div className="w-80 border-l bg-gray-50 overflow-y-auto flex-shrink-0">
|
|
<SummaryPanel meeting={meeting} onSummarize={handleSummarize} summarizing={summarizing} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Bottom: Recording Control Bar */}
|
|
<RecordingControlBar
|
|
isRecording={isRecording}
|
|
recordingTime={recordingTime}
|
|
currentSpeakerIdx={currentSpeakerIdx}
|
|
speakers={speakers}
|
|
sttLanguage={sttLanguage}
|
|
onStart={startRecording}
|
|
onStop={stopRecording}
|
|
onSwitchSpeaker={switchSpeaker}
|
|
onAddSpeaker={addSpeaker}
|
|
onLanguageChange={setSttLanguage}
|
|
onSummarize={handleSummarize}
|
|
onDiarize={handleDiarize}
|
|
saving={saving}
|
|
summarizing={summarizing}
|
|
diarizing={diarizing}
|
|
hasSegments={segments.length > 0}
|
|
hasAudio={!!meeting?.audio_gcs_uri}
|
|
analyserRef={analyserRef}
|
|
micGain={micGain}
|
|
onMicGainChange={setMicGain}
|
|
gainNodeRef={gainNodeRef}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ========== AutoResizeTextarea ==========
|
|
function AutoResizeTextarea({ value, onChange, className }) {
|
|
const ref = useRef(null);
|
|
useEffect(() => {
|
|
if (ref.current) {
|
|
ref.current.style.height = 'auto';
|
|
ref.current.style.height = ref.current.scrollHeight + 'px';
|
|
}
|
|
}, [value]);
|
|
return (
|
|
<textarea
|
|
ref={ref}
|
|
value={value}
|
|
onChange={onChange}
|
|
rows={1}
|
|
className={className}
|
|
style={{ resize: 'none', overflow: 'hidden' }}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// ========== ConversationView ==========
|
|
function ConversationView({ segments, interimText, isRecording, currentSpeaker, getSpeakerColor, editing, onEditText, onEditSpeaker, onDeleteSegment, speakers }) {
|
|
if (segments.length === 0 && !interimText) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center h-full text-gray-400">
|
|
<svg className="w-16 h-16 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" /></svg>
|
|
<p className="text-lg">녹음 버튼을 눌러 시작하세요</p>
|
|
<p className="text-sm mt-1">실시간 음성 인식이 대화를 기록합니다</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 편집 모드: 개별 세그먼트 렌더링
|
|
if (editing) {
|
|
// segments는 filter된 것이므로 원본 인덱스를 찾기 위해 전체 localSegments의 is_final 기준 인덱스 사용
|
|
// 부모에서 segments = localSegments.filter(s => s.is_final)로 전달하므로, segIdx는 filter된 배열이 아닌 원본 인덱스여야 함
|
|
// 하지만 onEditText 등은 localSegments 전체 배열의 인덱스를 사용하므로, 실제 인덱스를 매핑해야 함
|
|
// segments 배열은 is_final만 필터된 것이므로 원본 localSegments에서의 인덱스를 별도로 추적
|
|
return (
|
|
<div className="space-y-2">
|
|
<div className="text-xs text-gray-400 mb-2 flex items-center gap-1">
|
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
|
텍스트, 화자를 수정하거나 세그먼트를 삭제할 수 있습니다.
|
|
</div>
|
|
{segments.map((seg, filteredIdx) => {
|
|
const color = getSpeakerColor(seg.speaker_name);
|
|
// 원본 localSegments에서의 실제 인덱스 계산
|
|
// segments는 부모에서 localSegments.filter(s => s.is_final)로 전달됨
|
|
// filteredIdx → 원본 인덱스 매핑 필요
|
|
// seg 객체 자체가 localSegments의 참조이므로, 원본에서 찾기
|
|
const origIdx = seg.__origIdx !== undefined ? seg.__origIdx : filteredIdx;
|
|
return (
|
|
<div key={filteredIdx} className={`rounded-lg border-2 p-3 ${color.bg} ${color.border}`}>
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<select
|
|
value={seg.speaker_name}
|
|
onChange={(e) => onEditSpeaker(origIdx, e.target.value)}
|
|
className={`text-xs font-medium px-2 py-1 rounded-full border ${color.badge} cursor-pointer`}
|
|
>
|
|
{(speakers || []).map((sp, si) => (
|
|
<option key={si} value={sp.name}>{sp.name}</option>
|
|
))}
|
|
</select>
|
|
{seg.start_time_ms > 0 && (
|
|
<span className="text-xs text-gray-400">{formatMs(seg.start_time_ms)}</span>
|
|
)}
|
|
<div className="flex-1"></div>
|
|
<button
|
|
onClick={() => onDeleteSegment(origIdx)}
|
|
className="text-red-400 hover:text-red-600 hover:bg-red-50 p-1 rounded transition"
|
|
title="세그먼트 삭제"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
|
|
</button>
|
|
</div>
|
|
<AutoResizeTextarea
|
|
value={seg.text}
|
|
onChange={(e) => onEditText(origIdx, e.target.value)}
|
|
className="w-full text-sm text-gray-800 leading-relaxed bg-white/70 border border-gray-200 rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-400 focus:border-blue-400 outline-none"
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 읽기 모드: 연속 같은 화자 세그먼트 그룹화
|
|
const groups = [];
|
|
segments.forEach((seg, i) => {
|
|
if (i === 0 || seg.speaker_name !== segments[i - 1].speaker_name) {
|
|
groups.push({ speaker_name: seg.speaker_name, texts: [seg] });
|
|
} else {
|
|
groups[groups.length - 1].texts.push(seg);
|
|
}
|
|
});
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
{groups.map((group, gi) => {
|
|
const color = getSpeakerColor(group.speaker_name);
|
|
return (
|
|
<div key={gi} className={`rounded-lg border p-3 ${color.bg} ${color.border}`}>
|
|
<div className="flex items-center gap-2 mb-1.5">
|
|
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${color.badge}`}>
|
|
<span className={`w-2 h-2 rounded-full ${color.dot} mr-1`}></span>
|
|
{group.speaker_name}
|
|
</span>
|
|
{group.texts[0].start_time_ms > 0 && (
|
|
<span className="text-xs text-gray-400">{formatMs(group.texts[0].start_time_ms)}</span>
|
|
)}
|
|
</div>
|
|
<div className="text-sm text-gray-800 leading-relaxed">
|
|
{group.texts.map((t, ti) => <span key={ti}>{ti > 0 ? ' ' : ''}{t.text.replace(/[_\u2581]/g, ' ').replace(/\s{2,}/g, ' ').trim()}</span>)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{/* Interim (미확정) */}
|
|
{isRecording && interimText && (
|
|
<div className="rounded-lg border-2 border-dashed border-gray-300 p-3 bg-white">
|
|
<div className="flex items-center gap-2 mb-1.5">
|
|
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${getSpeakerColor(currentSpeaker.name).badge}`}>
|
|
<span className={`w-2 h-2 rounded-full bg-red-500 mr-1 animate-pulse`}></span>
|
|
{currentSpeaker.name}
|
|
</span>
|
|
<span className="text-xs text-gray-400">인식 중...</span>
|
|
</div>
|
|
<div className="text-sm text-gray-400 italic leading-relaxed">{interimText}</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ========== ScriptView ==========
|
|
function ScriptView({ segments, interimText, isRecording }) {
|
|
if (segments.length === 0 && !interimText) {
|
|
return <div className="text-center py-20 text-gray-400">스크립트가 없습니다.</div>;
|
|
}
|
|
|
|
return (
|
|
<div className="prose prose-sm max-w-none">
|
|
<div className="text-sm text-gray-800 leading-relaxed whitespace-pre-wrap">
|
|
{segments.map((s, i) => <span key={i}>{s.text.replace(/[_\u2581]/g, ' ').replace(/\s{2,}/g, ' ').trim()}{i < segments.length - 1 ? ' ' : ''}</span>)}
|
|
{isRecording && interimText && <span className="text-gray-400 italic"> {interimText}</span>}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ========== SummaryPanel ==========
|
|
function SummaryPanel({ meeting, onSummarize, summarizing }) {
|
|
const hasSummary = meeting.summary || (meeting.decisions && meeting.decisions.length > 0) || (meeting.action_items && meeting.action_items.length > 0);
|
|
|
|
return (
|
|
<div className="p-4">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="font-semibold text-gray-900 text-sm">AI 요약</h3>
|
|
<button onClick={onSummarize} disabled={summarizing} className="text-xs text-blue-600 hover:text-blue-700 disabled:text-gray-400 flex items-center gap-1">
|
|
{summarizing ? (
|
|
<><div className="animate-spin rounded-full h-3 w-3 border-b-2 border-blue-600"></div> 요약 중...</>
|
|
) : (
|
|
<><svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg> 요약 실행</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
|
|
{!hasSummary ? (
|
|
<div className="text-center py-8 text-gray-400">
|
|
<svg className="w-10 h-10 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" /></svg>
|
|
<p className="text-sm">녹음 완료 후 AI가 자동으로 요약합니다</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{meeting.summary && (
|
|
<div>
|
|
<h4 className="text-xs font-semibold text-gray-500 uppercase mb-1">회의 요약</h4>
|
|
<p className="text-sm text-gray-700 leading-relaxed">{meeting.summary}</p>
|
|
</div>
|
|
)}
|
|
|
|
{meeting.decisions && meeting.decisions.length > 0 && (
|
|
<div>
|
|
<h4 className="text-xs font-semibold text-gray-500 uppercase mb-1">결정사항</h4>
|
|
<ul className="space-y-1">
|
|
{meeting.decisions.map((d, i) => (
|
|
<li key={i} className="flex items-start gap-2 text-sm text-gray-700">
|
|
<span className="text-green-500 mt-0.5">✓</span>
|
|
<span>{d}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
{meeting.action_items && meeting.action_items.length > 0 && (
|
|
<div>
|
|
<h4 className="text-xs font-semibold text-gray-500 uppercase mb-1">액션 아이템</h4>
|
|
<ul className="space-y-2">
|
|
{meeting.action_items.map((item, i) => (
|
|
<li key={i} className="bg-white rounded border p-2">
|
|
<div className="text-sm text-gray-800">{item.task || item}</div>
|
|
<div className="flex items-center gap-2 mt-1 text-xs text-gray-500">
|
|
{item.assignee && <span className="bg-blue-50 text-blue-700 px-1.5 py-0.5 rounded">{item.assignee}</span>}
|
|
{item.deadline && <span className="bg-orange-50 text-orange-700 px-1.5 py-0.5 rounded">{item.deadline}</span>}
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ========== AudioLevelMeter ==========
|
|
function AudioLevelMeter({ analyserRef, isRecording }) {
|
|
const canvasRef = useRef(null);
|
|
const animFrameRef = useRef(null);
|
|
|
|
useEffect(() => {
|
|
if (!isRecording || !analyserRef?.current) {
|
|
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
|
|
// 녹음 중지 시 캔버스 클리어
|
|
const canvas = canvasRef.current;
|
|
if (canvas) {
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const analyser = analyserRef.current;
|
|
const dataArray = new Uint8Array(analyser.fftSize);
|
|
|
|
const draw = () => {
|
|
analyser.getByteTimeDomainData(dataArray);
|
|
|
|
// RMS 계산
|
|
let sum = 0;
|
|
for (let i = 0; i < dataArray.length; i++) {
|
|
const v = (dataArray[i] - 128) / 128;
|
|
sum += v * v;
|
|
}
|
|
const rms = Math.sqrt(sum / dataArray.length);
|
|
const level = Math.min(1, rms * 3); // 0~1 정규화
|
|
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
const ctx = canvas.getContext('2d');
|
|
const w = canvas.width;
|
|
const h = canvas.height;
|
|
ctx.clearRect(0, 0, w, h);
|
|
|
|
// 바 그리기
|
|
const barWidth = Math.round(w * level);
|
|
const gradient = ctx.createLinearGradient(0, 0, w, 0);
|
|
gradient.addColorStop(0, '#22c55e');
|
|
gradient.addColorStop(0.6, '#eab308');
|
|
gradient.addColorStop(1, '#ef4444');
|
|
ctx.fillStyle = gradient;
|
|
ctx.fillRect(0, 0, barWidth, h);
|
|
|
|
// 배경 바
|
|
ctx.fillStyle = 'rgba(0,0,0,0.06)';
|
|
ctx.fillRect(barWidth, 0, w - barWidth, h);
|
|
|
|
animFrameRef.current = requestAnimationFrame(draw);
|
|
};
|
|
|
|
draw();
|
|
|
|
return () => {
|
|
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
|
|
};
|
|
}, [isRecording, analyserRef?.current]);
|
|
|
|
return <canvas ref={canvasRef} width={120} height={12} className="rounded-full" style={{ display: 'block' }} />;
|
|
}
|
|
|
|
// ========== RecordingControlBar ==========
|
|
function RecordingControlBar({ isRecording, recordingTime, currentSpeakerIdx, speakers, sttLanguage, onStart, onStop, onSwitchSpeaker, onAddSpeaker, onLanguageChange, onSummarize, onDiarize, saving, summarizing, diarizing, hasSegments, hasAudio, analyserRef, micGain, onMicGainChange, gainNodeRef }) {
|
|
return (
|
|
<div className="bg-white border-t shadow-lg px-4 py-3 flex-shrink-0">
|
|
<div className="flex items-center justify-between gap-3">
|
|
{/* Language */}
|
|
<div className="flex items-center gap-2">
|
|
<select value={sttLanguage} onChange={(e) => onLanguageChange(e.target.value)} disabled={isRecording} className="text-sm border rounded px-2 py-1.5 bg-white disabled:bg-gray-100 disabled:text-gray-400">
|
|
{LANGUAGES.map(l => <option key={l.code} value={l.code}>{l.label}</option>)}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Speaker Selection */}
|
|
<div className="flex items-center gap-1">
|
|
{speakers.map((sp, idx) => {
|
|
const c = SPEAKER_COLORS[idx % SPEAKER_COLORS.length];
|
|
return (
|
|
<button key={idx} onClick={() => onSwitchSpeaker(idx)} className={`px-3 py-1.5 rounded-full text-xs font-medium transition ${currentSpeakerIdx === idx ? `${c.badge} ring-2 ring-offset-1 ring-blue-400` : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}>
|
|
{sp.name}
|
|
</button>
|
|
);
|
|
})}
|
|
{speakers.length < 4 && (
|
|
<button onClick={onAddSpeaker} disabled={isRecording} className="px-2 py-1.5 rounded-full text-xs text-gray-400 hover:text-gray-600 hover:bg-gray-100 disabled:opacity-50" title="화자 추가">+</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Mic Gain Slider */}
|
|
{isRecording && (
|
|
<div className="flex items-center gap-1.5">
|
|
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" /></svg>
|
|
<input
|
|
type="range"
|
|
min="0.5"
|
|
max="3.0"
|
|
step="0.1"
|
|
value={micGain}
|
|
onChange={(e) => {
|
|
const val = parseFloat(e.target.value);
|
|
onMicGainChange(val);
|
|
if (gainNodeRef?.current) gainNodeRef.current.gain.value = val;
|
|
}}
|
|
className="w-16 h-1 accent-blue-500"
|
|
title={`감도 ${micGain.toFixed(1)}x`}
|
|
/>
|
|
<span className="text-xs text-gray-400 min-w-[28px]">{micGain.toFixed(1)}x</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Record Button + VU Meter */}
|
|
<div className="flex items-center gap-3">
|
|
{isRecording ? (
|
|
<button onClick={onStop} disabled={saving} className="bg-red-600 text-white px-5 py-2 rounded-full hover:bg-red-700 transition flex items-center gap-2 text-sm font-medium shadow-lg disabled:opacity-50">
|
|
<span className="w-3 h-3 bg-white rounded-sm"></span>
|
|
중지
|
|
</button>
|
|
) : (
|
|
<button onClick={onStart} disabled={saving || diarizing} className="bg-red-500 text-white px-5 py-2 rounded-full hover:bg-red-600 transition flex items-center gap-2 text-sm font-medium shadow-lg disabled:opacity-50">
|
|
<span className="w-3 h-3 bg-white rounded-full"></span>
|
|
녹음
|
|
</button>
|
|
)}
|
|
{isRecording && <AudioLevelMeter analyserRef={analyserRef} isRecording={isRecording} />}
|
|
<span className="text-sm font-mono text-gray-600 min-w-[60px]">{formatDuration(recordingTime)}</span>
|
|
{isRecording && <span className="flex items-center gap-1 text-xs text-red-500"><span className="w-2 h-2 bg-red-500 rounded-full animate-pulse"></span>REC</span>}
|
|
</div>
|
|
|
|
{/* Status indicators */}
|
|
<div className="flex items-center gap-2">
|
|
{saving && <span className="text-xs text-blue-500 flex items-center gap-1"><div className="animate-spin rounded-full h-3 w-3 border-b-2 border-blue-500"></div>저장 중...</span>}
|
|
{summarizing && <span className="text-xs text-purple-500 flex items-center gap-1"><div className="animate-spin rounded-full h-3 w-3 border-b-2 border-purple-500"></div>요약 중...</span>}
|
|
{diarizing && <span className="text-xs text-indigo-500 flex items-center gap-1"><div className="animate-spin rounded-full h-3 w-3 border-b-2 border-indigo-500"></div>화자 분리 중...</span>}
|
|
</div>
|
|
|
|
{/* Action Buttons */}
|
|
<div className="flex items-center gap-2">
|
|
{/* 자동 화자 분리 버튼 */}
|
|
<button onClick={onDiarize} disabled={diarizing || summarizing || isRecording || !hasAudio} className="bg-indigo-600 text-white px-3 py-1.5 rounded-lg hover:bg-indigo-700 transition text-xs font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1" title="업로드된 오디오에서 AI로 자동 화자 구분">
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
|
|
화자 분리
|
|
</button>
|
|
{/* AI 요약 버튼 */}
|
|
<button onClick={onSummarize} disabled={summarizing || diarizing || isRecording || !hasSegments} className="bg-purple-600 text-white px-3 py-1.5 rounded-lg hover:bg-purple-700 transition text-xs font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1">
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" /></svg>
|
|
AI 요약
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ========== App ==========
|
|
function App() {
|
|
const [currentView, setCurrentView] = useState('list');
|
|
const [selectedId, setSelectedId] = useState(null);
|
|
const [toast, setToast] = useState(null);
|
|
|
|
const showToast = useCallback((message, type = 'success') => {
|
|
setToast({ message, type });
|
|
}, []);
|
|
|
|
const handleSelect = (id) => {
|
|
setSelectedId(id);
|
|
setCurrentView('detail');
|
|
};
|
|
|
|
const handleBack = () => {
|
|
setSelectedId(null);
|
|
setCurrentView('list');
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<ToastNotification toast={toast} onClose={() => setToast(null)} />
|
|
{currentView === 'list' ? (
|
|
<MeetingList onSelect={handleSelect} showToast={showToast} />
|
|
) : (
|
|
<MeetingDetail meetingId={selectedId} onBack={handleBack} showToast={showToast} />
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
const root = ReactDOM.createRoot(document.getElementById('root'));
|
|
root.render(<App />);
|
|
@endverbatim
|
|
</script>
|
|
@endpush
|