Files
meeting_ai/client/src/App.tsx
2026-02-01 10:54:28 +09:00

433 lines
14 KiB
TypeScript

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<number | null>(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<Set<number>>(
new Set()
)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [micLevel, setMicLevel] = useState(0)
const lineIdRef = useRef(1)
const meetingIdRef = useRef<number | null>(null)
const recognitionRef = useRef<SpeechRecognition | null>(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<number | null>(null)
const resetPendingRef = useRef(false)
const lastFinalTextRef = useRef('')
const lastFinalAtRef = useRef(0)
const audioContextRef = useRef<AudioContext | null>(null)
const hasSpeechRecognition = useMemo(() => {
return 'SpeechRecognition' in window || 'webkitSpeechRecognition' in window
}, [])
const analyserRef = useRef<AnalyserNode | null>(null)
const mediaStreamRef = useRef<MediaStream | null>(null)
const meterRafRef = useRef<number | null>(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 (
<div className="app">
<div className="left-panel">
{errorMessage && <div className="error-banner">{errorMessage}</div>}
<TranscriptPanel
transcriptLines={transcriptLines}
interimText=""
isRecording={isRecording}
/>
<div className="controls">
<button
type="button"
className={`record-btn ${isRecording ? 'recording' : ''}`}
onClick={handleStart}
disabled={isRecording || !hasSpeechRecognition}
>
</button>
<button type="button" className="stop-btn" onClick={handleStop} disabled={!isRecording}>
</button>
<div className="mic-meter" aria-hidden="true">
<div
className="mic-meter-bar"
style={{ width: `${Math.round(micLevel * 100)}%` }}
/>
<span className="mic-meter-label">{Math.round(micLevel * 100)}%</span>
</div>
</div>
</div>
<div className="right-panel">
<div className="panel-title"> </div>
<MeetingList
meetings={meetingsList}
isEditMode={isEditMode}
selectedIds={selectedMeetingIds}
allSelected={meetingsList.length > 0 && selectedMeetingIds.size === meetingsList.length}
onToggleSelect={handleToggleSelect}
onToggleAll={handleToggleAll}
onSelectMeeting={handleSelectMeeting}
/>
<div className="list-controls">
{!isEditMode ? (
<button type="button" className="edit-btn" onClick={handleToggleEdit}>
</button>
) : (
<>
<button type="button" className="delete-btn" onClick={handleDelete}>
</button>
<button type="button" className="cancel-btn" onClick={handleCancelEdit}>
</button>
</>
)}
</div>
</div>
</div>
)
}
export default App