import { useEffect, useMemo, useRef, useState } from 'react' import './App.css' import TranscriptPanel from './components/TranscriptPanel' import MeetingList from './components/MeetingList' import { createMeeting, deleteMeetings, endMeeting, fetchMeeting, fetchMeetings } from './lib/api' function App() { const [isRecording, setIsRecording] = useState(false) const [isEditMode, setIsEditMode] = useState(false) const [currentMeetingId, setCurrentMeetingId] = useState(null) const [transcriptLines, setTranscriptLines] = useState< { id: number; ts: string; text: string; isFinal: boolean }[] >([]) const [meetingsList, setMeetingsList] = useState< { id: number; started_at: string; ended_at: string | null; title: string | null }[] >([]) const [selectedMeetingIds, setSelectedMeetingIds] = useState>( new Set() ) const [errorMessage, setErrorMessage] = useState(null) const [micLevel, setMicLevel] = useState(0) const lineIdRef = useRef(1) const meetingIdRef = useRef(null) const recognitionRef = useRef(null) const isRecordingRef = useRef(false) const stopRequestedRef = useRef(false) const maxLinesRef = useRef(500) const micLevelRef = useRef(0) const pendingTranscriptRef = useRef('') const lastAutoFinalAtRef = useRef(0) const lastAutoFinalTextRef = useRef('') const lastVoiceAtRef = useRef(0) const lastSilenceFinalAtRef = useRef(0) const lastResultAtRef = useRef(0) const noResultTimerRef = useRef(null) const resetPendingRef = useRef(false) const lastFinalTextRef = useRef('') const lastFinalAtRef = useRef(0) const audioContextRef = useRef(null) const hasSpeechRecognition = useMemo(() => { return 'SpeechRecognition' in window || 'webkitSpeechRecognition' in window }, []) const analyserRef = useRef(null) const mediaStreamRef = useRef(null) const meterRafRef = useRef(null) useEffect(() => { fetchMeetings() .then(setMeetingsList) .catch((err) => setErrorMessage(err.message)) }, []) const clearNoResultTimer = () => { if (noResultTimerRef.current !== null) { window.clearTimeout(noResultTimerRef.current) noResultTimerRef.current = null } } const scheduleNoResultReset = () => { clearNoResultTimer() if (!isRecordingRef.current || stopRequestedRef.current) return noResultTimerRef.current = window.setTimeout(() => { noResultTimerRef.current = null if (!isRecordingRef.current || stopRequestedRef.current) return const now = Date.now() if (now - lastResultAtRef.current < 1500) return if (resetPendingRef.current) return resetPendingRef.current = true try { recognitionRef.current?.stop() } catch { // ignore stop errors } window.setTimeout(() => { resetPendingRef.current = false if (isRecordingRef.current && !stopRequestedRef.current) { startRecognition() } }, 300) }, 1500) } const appendFinalLine = (text: string) => { const trimmed = text.trim() if (!trimmed) return const tokenCount = trimmed.split(/\s+/).filter(Boolean).length if (tokenCount < 2) return const now = Date.now() if (now - lastFinalAtRef.current < 1200) { const last = lastFinalTextRef.current if (last && (last.includes(trimmed) || trimmed.includes(last))) { return } } const ts = new Date().toISOString() setTranscriptLines((prev) => { const next = [...prev, { id: lineIdRef.current++, ts, text: trimmed, isFinal: true }] const overflow = next.length - maxLinesRef.current return overflow > 0 ? next.slice(overflow) : next }) lastFinalTextRef.current = trimmed lastFinalAtRef.current = now } const startRecognition = () => { const SpeechRecognitionConstructor = window.SpeechRecognition || window.webkitSpeechRecognition if (!SpeechRecognitionConstructor) { setErrorMessage('이 브라우저에서는 STT를 지원하지 않습니다. Chrome을 사용해 주세요.') return } const recognition = recognitionRef.current ?? new SpeechRecognitionConstructor() recognition.lang = 'ko-KR' recognition.interimResults = true recognition.continuous = true recognition.maxAlternatives = 1 recognition.onresult = (event) => { lastResultAtRef.current = Date.now() for (let i = event.resultIndex; i < event.results.length; i += 1) { const result = event.results[i] if (!result || !result[0]) continue const transcript = result[0].transcript if (!transcript) continue pendingTranscriptRef.current = transcript if (result.isFinal) { appendFinalLine(transcript) } } scheduleNoResultReset() } recognition.onerror = (event: SpeechRecognitionErrorEvent) => { const errorCode = event?.error if (errorCode === 'aborted' || errorCode === 'no-speech') { return } setErrorMessage('음성 인식 중 오류가 발생했습니다.') } recognition.onend = () => { if (isRecordingRef.current && !stopRequestedRef.current) { window.setTimeout(() => { startRecognition() }, 200) } } recognitionRef.current = recognition try { recognition.start() } catch { // ignore start errors } scheduleNoResultReset() } useEffect(() => { return () => { stopMeter() } }, []) const startMeter = async () => { if (meterRafRef.current !== null) return try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }) mediaStreamRef.current = stream const AudioCtx = window.AudioContext || (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext if (!AudioCtx) return const audioContext = new AudioCtx() audioContextRef.current = audioContext const analyser = audioContext.createAnalyser() analyser.fftSize = 2048 analyserRef.current = analyser const source = audioContext.createMediaStreamSource(stream) source.connect(analyser) const data = new Uint8Array(analyser.fftSize) const loop = () => { if (!analyserRef.current) return analyserRef.current.getByteTimeDomainData(data) let sum = 0 for (let i = 0; i < data.length; i += 1) { const v = (data[i] - 128) / 128 sum += v * v } const rms = Math.sqrt(sum / data.length) const scaled = Math.log10(1 + rms * 120) / Math.log10(121) const level = Math.min(1, Math.max(0, scaled)) const smooth = level * 0.5 + micLevelRef.current * 0.5 micLevelRef.current = smooth setMicLevel(smooth) if (isRecordingRef.current) { const percent = smooth * 100 const now = Date.now() if (percent >= 8) { lastVoiceAtRef.current = now } const silenceMs = now - lastVoiceAtRef.current if ( silenceMs >= 900 && pendingTranscriptRef.current && now - lastSilenceFinalAtRef.current > 1200 && pendingTranscriptRef.current !== lastAutoFinalTextRef.current ) { appendFinalLine(pendingTranscriptRef.current) lastSilenceFinalAtRef.current = now lastAutoFinalAtRef.current = now lastAutoFinalTextRef.current = pendingTranscriptRef.current pendingTranscriptRef.current = '' } } meterRafRef.current = window.requestAnimationFrame(loop) } meterRafRef.current = window.requestAnimationFrame(loop) } catch (err) { setErrorMessage((err as Error).message) } } const stopMeter = () => { if (meterRafRef.current !== null) { window.cancelAnimationFrame(meterRafRef.current) meterRafRef.current = null } if (analyserRef.current) { analyserRef.current.disconnect() analyserRef.current = null } if (audioContextRef.current) { audioContextRef.current.close().catch(() => undefined) audioContextRef.current = null } if (mediaStreamRef.current) { mediaStreamRef.current.getTracks().forEach((track) => track.stop()) mediaStreamRef.current = null } setMicLevel(0) micLevelRef.current = 0 pendingTranscriptRef.current = '' lastAutoFinalAtRef.current = 0 lastAutoFinalTextRef.current = '' lastVoiceAtRef.current = 0 lastSilenceFinalAtRef.current = 0 clearNoResultTimer() } const handleStart = async () => { setErrorMessage(null) lineIdRef.current = 1 setTranscriptLines([]) meetingIdRef.current = null setCurrentMeetingId(null) setIsRecording(true) isRecordingRef.current = true stopRequestedRef.current = false lastResultAtRef.current = 0 resetPendingRef.current = false lastVoiceAtRef.current = 0 lastSilenceFinalAtRef.current = 0 scheduleNoResultReset() pendingTranscriptRef.current = '' lastAutoFinalAtRef.current = 0 lastAutoFinalTextRef.current = '' if (hasSpeechRecognition) { startRecognition() } void startMeter() try { const result = await createMeeting(new Date().toISOString()) meetingIdRef.current = result.id setCurrentMeetingId(result.id) } catch (err) { setErrorMessage((err as Error).message) } } const handleStop = async () => { setErrorMessage(null) setIsRecording(false) isRecordingRef.current = false stopRequestedRef.current = true lastVoiceAtRef.current = 0 lastSilenceFinalAtRef.current = 0 clearNoResultTimer() pendingTranscriptRef.current = '' recognitionRef.current?.stop() stopMeter() try { if (meetingIdRef.current) { await endMeeting(meetingIdRef.current, new Date().toISOString()) const list = await fetchMeetings() setMeetingsList(list) } } catch (err) { setErrorMessage((err as Error).message) } } const handleSelectMeeting = async (id: number) => { setErrorMessage(null) try { const data = await fetchMeeting(id) meetingIdRef.current = id setCurrentMeetingId(id) lineIdRef.current = 1 setTranscriptLines( data.utterances.map((utterance) => ({ id: lineIdRef.current++, ts: utterance.ts, text: utterance.text, isFinal: true, })) ) } catch (err) { setErrorMessage((err as Error).message) } } const handleToggleEdit = () => { setIsEditMode(true) } const handleCancelEdit = () => { setIsEditMode(false) setSelectedMeetingIds(new Set()) } const handleToggleSelect = (id: number) => { const next = new Set(selectedMeetingIds) if (next.has(id)) { next.delete(id) } else { next.add(id) } setSelectedMeetingIds(next) } const handleToggleAll = () => { if (meetingsList.length === 0) return if (selectedMeetingIds.size === meetingsList.length) { setSelectedMeetingIds(new Set()) return } setSelectedMeetingIds(new Set(meetingsList.map((meeting) => meeting.id))) } const handleDelete = async () => { if (selectedMeetingIds.size === 0) return setErrorMessage(null) const ids = Array.from(selectedMeetingIds) try { await deleteMeetings(ids) const updated = meetingsList.filter((meeting) => !selectedMeetingIds.has(meeting.id)) setMeetingsList(updated) setSelectedMeetingIds(new Set()) setIsEditMode(false) if (currentMeetingId && ids.includes(currentMeetingId)) { setCurrentMeetingId(null) meetingIdRef.current = null setTranscriptLines([]) } } catch (err) { setErrorMessage((err as Error).message) } } return (
{errorMessage &&
{errorMessage}
}
대화 리스트
0 && selectedMeetingIds.size === meetingsList.length} onToggleSelect={handleToggleSelect} onToggleAll={handleToggleAll} onSelectMeeting={handleSelectMeeting} />
{!isEditMode ? ( ) : ( <> )}
) } export default App