Compare commits

...

10 Commits

Author SHA1 Message Date
dsyoon
3e3cafe6ea Update mic meter and speech handling 2026-02-01 10:54:28 +09:00
dsyoon
a8a51a19c1 Update plan deck 2026-01-30 21:42:42 +09:00
dsyoon
a5a50f67ed Update client title and dev typings 2026-01-30 21:41:11 +09:00
dsyoon
17e61f4b18 Render live STT without finalize 2026-01-28 23:04:40 +09:00
dsyoon
b4f26ddfb7 Finalize only on final results 2026-01-28 22:58:12 +09:00
dsyoon
19cc1c1360 Start STT immediately and drop answers 2026-01-28 22:50:10 +09:00
dsyoon
7ce9a73793 Remove LLM question detection 2026-01-28 22:29:05 +09:00
dsyoon
fa6579897c Restart server if port is in use 2026-01-28 22:21:33 +09:00
dsyoon
a0302ed5ac Add maxAlternatives type 2026-01-28 22:19:36 +09:00
dsyoon
c05da7dc9e Stabilize interim speech capture 2026-01-28 22:18:05 +09:00
10 changed files with 377 additions and 215 deletions

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>client</title> <title>글소리 (미팅/회의를 텍스트로 변환)</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -42,6 +42,16 @@
font-size: 16px; font-size: 16px;
} }
.interim-box {
background: #f3f4f6;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 10px 12px;
font-size: 14px;
color: #374151;
min-height: 42px;
}
.transcript-panel { .transcript-panel {
flex: 3 1 0; flex: 3 1 0;
min-height: 0; min-height: 0;
@@ -103,6 +113,7 @@
.controls { .controls {
display: flex; display: flex;
gap: 12px; gap: 12px;
align-items: center;
} }
button { button {
@@ -114,8 +125,8 @@ button {
} }
.record-btn { .record-btn {
background: #e5e7eb; background: #fca5a5;
color: #111827; color: #7f1d1d;
} }
.record-btn.recording { .record-btn.recording {
@@ -129,11 +140,34 @@ button {
color: #fff; color: #fff;
} }
.save-btn { .mic-meter {
background: #2563eb; position: relative;
color: #fff; width: 140px;
height: 10px;
background: #e5e7eb;
border-radius: 999px;
overflow: hidden;
} }
.mic-meter-bar {
height: 100%;
background: #ef4444;
width: 0%;
transition: width 80ms linear;
}
.mic-meter-label {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
font-size: 10px;
font-weight: 600;
color: #111827;
pointer-events: none;
}
.meeting-list { .meeting-list {
flex: 1; flex: 1;
border: 1px solid #f0f0f0; border: 1px solid #f0f0f0;
@@ -143,6 +177,25 @@ button {
background: #fafafa; background: #fafafa;
} }
.meeting-list-toolbar {
display: flex;
justify-content: flex-end;
margin-bottom: 8px;
}
.select-all-btn {
background: #e5e7eb;
color: #111827;
padding: 6px 12px;
border-radius: 999px;
font-size: 12px;
}
.select-all-btn.active {
background: #111827;
color: #fff;
}
.meeting-item { .meeting-item {
margin-bottom: 10px; margin-bottom: 10px;
} }

View File

@@ -1,18 +1,8 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import './App.css' import './App.css'
import TranscriptPanel from './components/TranscriptPanel' import TranscriptPanel from './components/TranscriptPanel'
import AnswerPanel from './components/AnswerPanel'
import MeetingList from './components/MeetingList' import MeetingList from './components/MeetingList'
import { import { createMeeting, deleteMeetings, endMeeting, fetchMeeting, fetchMeetings } from './lib/api'
createMeeting,
deleteMeetings,
endMeeting,
fetchAnswerSuggestions,
fetchMeeting,
fetchMeetings,
saveAnswers,
saveUtterance,
} from './lib/api'
function App() { function App() {
const [isRecording, setIsRecording] = useState(false) const [isRecording, setIsRecording] = useState(false)
@@ -21,7 +11,6 @@ function App() {
const [transcriptLines, setTranscriptLines] = useState< const [transcriptLines, setTranscriptLines] = useState<
{ id: number; ts: string; text: string; isFinal: boolean }[] { id: number; ts: string; text: string; isFinal: boolean }[]
>([]) >([])
const [answerSuggestions, setAnswerSuggestions] = useState<string[]>([])
const [meetingsList, setMeetingsList] = useState< const [meetingsList, setMeetingsList] = useState<
{ id: number; started_at: string; ended_at: string | null; title: string | null }[] { id: number; started_at: string; ended_at: string | null; title: string | null }[]
>([]) >([])
@@ -29,18 +18,31 @@ function App() {
new Set() new Set()
) )
const [errorMessage, setErrorMessage] = useState<string | null>(null) const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [micLevel, setMicLevel] = useState(0)
const recognitionRef = useRef<SpeechRecognition | null>(null)
const liveTextRef = useRef('')
const lineIdRef = useRef(1) const lineIdRef = useRef(1)
const meetingIdRef = useRef<number | null>(null) const meetingIdRef = useRef<number | null>(null)
const recognitionRef = useRef<SpeechRecognition | null>(null)
const isRecordingRef = useRef(false) const isRecordingRef = useRef(false)
const lastResultAtRef = useRef<number>(Date.now()) const stopRequestedRef = useRef(false)
const restartLockRef = 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(() => { const hasSpeechRecognition = useMemo(() => {
return 'SpeechRecognition' in window || 'webkitSpeechRecognition' in window 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(() => { useEffect(() => {
fetchMeetings() fetchMeetings()
@@ -48,227 +50,250 @@ function App() {
.catch((err) => setErrorMessage(err.message)) .catch((err) => setErrorMessage(err.message))
}, []) }, [])
useEffect(() => { const clearNoResultTimer = () => {
if (!isRecording) return if (noResultTimerRef.current !== null) {
const intervalId = window.setInterval(() => { window.clearTimeout(noResultTimerRef.current)
if (!isRecordingRef.current) return noResultTimerRef.current = null
const now = Date.now()
if (now - lastResultAtRef.current > 4000) {
void safeRestartRecognition()
}
}, 2000)
return () => window.clearInterval(intervalId)
}, [isRecording])
const commitLiveIfAny = async () => {
if (!meetingIdRef.current) return
const text = liveTextRef.current.trim()
if (!text) return
const ts = new Date().toISOString()
setTranscriptLines((prev) => {
const last = prev[prev.length - 1]
if (last && !last.isFinal) {
return [
...prev.slice(0, -1),
{ ...last, text, ts, isFinal: true },
]
}
return [...prev, { id: lineIdRef.current++, ts, text, isFinal: true }]
})
try {
await saveUtterance(meetingIdRef.current, text, ts)
} catch (err) {
setErrorMessage((err as Error).message)
} }
} }
const detectQuestion = (text: string) => { 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() const trimmed = text.trim()
if (!trimmed) return false if (!trimmed) return
if (trimmed.includes('?')) return true const tokenCount = trimmed.split(/\s+/).filter(Boolean).length
const patterns = [ 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
return patterns.some((pattern) => trimmed.includes(pattern)) lastFinalAtRef.current = now
} }
const startRecognition = () => { const startRecognition = () => {
const SpeechRecognitionConstructor = const SpeechRecognitionConstructor =
window.SpeechRecognition || window.webkitSpeechRecognition window.SpeechRecognition || window.webkitSpeechRecognition
if (!SpeechRecognitionConstructor) { if (!SpeechRecognitionConstructor) {
setErrorMessage('이 브라우저에서는 STT를 지원하지 않습니다. Chrome을 사용해 주세요.') setErrorMessage('이 브라우저에서는 STT를 지원하지 않습니다. Chrome을 사용해 주세요.')
return return
} }
const recognition = recognitionRef.current ?? new SpeechRecognitionConstructor()
const recognition = new SpeechRecognitionConstructor()
recognition.lang = 'ko-KR' recognition.lang = 'ko-KR'
recognition.interimResults = true recognition.interimResults = true
recognition.continuous = true recognition.continuous = true
recognition.maxAlternatives = 1
recognition.onresult = (event) => { recognition.onresult = (event) => {
lastResultAtRef.current = Date.now() lastResultAtRef.current = Date.now()
let interim = ''
for (let i = event.resultIndex; i < event.results.length; i += 1) { for (let i = event.resultIndex; i < event.results.length; i += 1) {
const result = event.results[i] const result = event.results[i]
const text = result[0].transcript if (!result || !result[0]) continue
const transcript = result[0].transcript
if (!transcript) continue
pendingTranscriptRef.current = transcript
if (result.isFinal) { if (result.isFinal) {
handleFinalTranscript(text) appendFinalLine(transcript)
} else {
interim += text
} }
} }
const interimText = interim.trim() scheduleNoResultReset()
if (interimText) {
liveTextRef.current = interimText
setTranscriptLines((prev) => {
const last = prev[prev.length - 1]
if (last && !last.isFinal) {
return [...prev.slice(0, -1), { ...last, text: interimText }]
}
return [
...prev,
{
id: lineIdRef.current++,
ts: new Date().toISOString(),
text: interimText,
isFinal: false,
},
]
})
}
} }
recognition.onerror = () => { recognition.onerror = (event: SpeechRecognitionErrorEvent) => {
const errorCode = event?.error
if (errorCode === 'aborted' || errorCode === 'no-speech') {
return
}
setErrorMessage('음성 인식 중 오류가 발생했습니다.') setErrorMessage('음성 인식 중 오류가 발생했습니다.')
} }
recognition.onend = () => { recognition.onend = () => {
void commitLiveIfAny() if (isRecordingRef.current && !stopRequestedRef.current) {
liveTextRef.current = ''
if (isRecordingRef.current) {
window.setTimeout(() => { window.setTimeout(() => {
void safeRestartRecognition() startRecognition()
}, 200) }, 200)
} else {
setIsRecording(false)
} }
} }
recognitionRef.current = recognition recognitionRef.current = recognition
recognition.start() try {
recognition.start()
} catch {
// ignore start errors
}
scheduleNoResultReset()
} }
const handleFinalTranscript = async (text: string) => { useEffect(() => {
if (!meetingIdRef.current) return return () => {
const trimmed = text.trim() stopMeter()
if (!trimmed) return }
lastResultAtRef.current = Date.now() }, [])
const ts = new Date().toISOString()
liveTextRef.current = ''
let nextLines: { id: number; ts: string; text: string; isFinal: boolean }[] = []
setTranscriptLines((prev) => {
const last = prev[prev.length - 1]
if (last && !last.isFinal) {
nextLines = [
...prev.slice(0, -1),
{ ...last, text: trimmed, ts, isFinal: true },
]
return nextLines
}
nextLines = [
...prev,
{ id: lineIdRef.current++, ts, text: trimmed, isFinal: true },
]
return nextLines
})
const startMeter = async () => {
if (meterRafRef.current !== null) return
try { try {
await saveUtterance(meetingIdRef.current, trimmed, ts) 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) { } catch (err) {
setErrorMessage((err as Error).message) setErrorMessage((err as Error).message)
} }
}
if (detectQuestion(trimmed)) { const stopMeter = () => {
try { if (meterRafRef.current !== null) {
const context = nextLines.slice(-20).map((line) => line.text) window.cancelAnimationFrame(meterRafRef.current)
const result = await fetchAnswerSuggestions(context, trimmed) meterRafRef.current = null
setAnswerSuggestions(result.suggestions)
await saveAnswers(meetingIdRef.current, trimmed, result.suggestions)
} catch (err) {
setErrorMessage((err as Error).message)
}
} }
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 () => { const handleStart = async () => {
setErrorMessage(null) 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 { try {
const result = await createMeeting(new Date().toISOString()) const result = await createMeeting(new Date().toISOString())
meetingIdRef.current = result.id meetingIdRef.current = result.id
setCurrentMeetingId(result.id) setCurrentMeetingId(result.id)
lineIdRef.current = 1
setTranscriptLines([])
setAnswerSuggestions([])
setIsRecording(true)
isRecordingRef.current = true
lastResultAtRef.current = Date.now()
startRecognition()
} catch (err) { } catch (err) {
setErrorMessage((err as Error).message) setErrorMessage((err as Error).message)
} }
} }
const handleStop = async () => { const handleStop = async () => {
if (!meetingIdRef.current) return
setErrorMessage(null) setErrorMessage(null)
recognitionRef.current?.stop()
await commitLiveIfAny()
liveTextRef.current = ''
setIsRecording(false) setIsRecording(false)
isRecordingRef.current = false isRecordingRef.current = false
stopRequestedRef.current = true
lastVoiceAtRef.current = 0
lastSilenceFinalAtRef.current = 0
clearNoResultTimer()
pendingTranscriptRef.current = ''
recognitionRef.current?.stop()
stopMeter()
try { try {
await endMeeting(meetingIdRef.current, new Date().toISOString()) if (meetingIdRef.current) {
const list = await fetchMeetings() await endMeeting(meetingIdRef.current, new Date().toISOString())
setMeetingsList(list) const list = await fetchMeetings()
} catch (err) { setMeetingsList(list)
setErrorMessage((err as Error).message) }
}
}
const safeRestartRecognition = async () => {
if (!recognitionRef.current || restartLockRef.current) return
restartLockRef.current = true
try {
recognitionRef.current.stop()
recognitionRef.current.start()
lastResultAtRef.current = Date.now()
} catch {
// ignore restart errors
} finally {
window.setTimeout(() => {
restartLockRef.current = false
}, 500)
}
}
const handleSave = async () => {
if (!meetingIdRef.current) return
setErrorMessage(null)
try {
await endMeeting(meetingIdRef.current, new Date().toISOString())
} catch (err) { } catch (err) {
setErrorMessage((err as Error).message) setErrorMessage((err as Error).message)
} }
@@ -289,8 +314,6 @@ function App() {
isFinal: true, isFinal: true,
})) }))
) )
const lastAnswer = data.answers[data.answers.length - 1]
setAnswerSuggestions(lastAnswer?.suggestions || [])
} catch (err) { } catch (err) {
setErrorMessage((err as Error).message) setErrorMessage((err as Error).message)
} }
@@ -315,6 +338,15 @@ function App() {
setSelectedMeetingIds(next) 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 () => { const handleDelete = async () => {
if (selectedMeetingIds.size === 0) return if (selectedMeetingIds.size === 0) return
setErrorMessage(null) setErrorMessage(null)
@@ -329,7 +361,6 @@ function App() {
setCurrentMeetingId(null) setCurrentMeetingId(null)
meetingIdRef.current = null meetingIdRef.current = null
setTranscriptLines([]) setTranscriptLines([])
setAnswerSuggestions([])
} }
} catch (err) { } catch (err) {
setErrorMessage((err as Error).message) setErrorMessage((err as Error).message)
@@ -340,8 +371,11 @@ function App() {
<div className="app"> <div className="app">
<div className="left-panel"> <div className="left-panel">
{errorMessage && <div className="error-banner">{errorMessage}</div>} {errorMessage && <div className="error-banner">{errorMessage}</div>}
<TranscriptPanel transcriptLines={transcriptLines} /> <TranscriptPanel
<AnswerPanel suggestions={answerSuggestions} /> transcriptLines={transcriptLines}
interimText=""
isRecording={isRecording}
/>
<div className="controls"> <div className="controls">
<button <button
type="button" type="button"
@@ -354,13 +388,14 @@ function App() {
<button type="button" className="stop-btn" onClick={handleStop} disabled={!isRecording}> <button type="button" className="stop-btn" onClick={handleStop} disabled={!isRecording}>
</button> </button>
<button type="button" className="save-btn" onClick={handleSave} disabled={!currentMeetingId}> <div className="mic-meter" aria-hidden="true">
<div
</button> className="mic-meter-bar"
style={{ width: `${Math.round(micLevel * 100)}%` }}
/>
<span className="mic-meter-label">{Math.round(micLevel * 100)}%</span>
</div>
</div> </div>
{!hasSpeechRecognition && (
<div className="hint">Chrome에서만 Web Speech API가 .</div>
)}
</div> </div>
<div className="right-panel"> <div className="right-panel">
<div className="panel-title"> </div> <div className="panel-title"> </div>
@@ -368,7 +403,9 @@ function App() {
meetings={meetingsList} meetings={meetingsList}
isEditMode={isEditMode} isEditMode={isEditMode}
selectedIds={selectedMeetingIds} selectedIds={selectedMeetingIds}
allSelected={meetingsList.length > 0 && selectedMeetingIds.size === meetingsList.length}
onToggleSelect={handleToggleSelect} onToggleSelect={handleToggleSelect}
onToggleAll={handleToggleAll}
onSelectMeeting={handleSelectMeeting} onSelectMeeting={handleSelectMeeting}
/> />
<div className="list-controls"> <div className="list-controls">

View File

@@ -4,7 +4,9 @@ type Props = {
meetings: MeetingSummary[] meetings: MeetingSummary[]
isEditMode: boolean isEditMode: boolean
selectedIds: Set<number> selectedIds: Set<number>
allSelected: boolean
onToggleSelect: (id: number) => void onToggleSelect: (id: number) => void
onToggleAll: () => void
onSelectMeeting: (id: number) => void onSelectMeeting: (id: number) => void
} }
@@ -24,11 +26,24 @@ export default function MeetingList({
meetings, meetings,
isEditMode, isEditMode,
selectedIds, selectedIds,
allSelected,
onToggleSelect, onToggleSelect,
onToggleAll,
onSelectMeeting, onSelectMeeting,
}: Props) { }: Props) {
return ( return (
<div className="meeting-list"> <div className="meeting-list">
{isEditMode && (
<div className="meeting-list-toolbar">
<button
type="button"
className={`select-all-btn ${allSelected ? 'active' : ''}`.trim()}
onClick={onToggleAll}
>
</button>
</div>
)}
{meetings.length === 0 && ( {meetings.length === 0 && (
<div className="placeholder"> .</div> <div className="placeholder"> .</div>
)} )}

View File

@@ -7,12 +7,17 @@ type TranscriptLine = {
type Props = { type Props = {
transcriptLines: TranscriptLine[] transcriptLines: TranscriptLine[]
interimText: string
isRecording: boolean
} }
export default function TranscriptPanel({ transcriptLines }: Props) { export default function TranscriptPanel({ transcriptLines, interimText, isRecording }: Props) {
return ( return (
<div className="panel transcript-panel"> <div className="panel transcript-panel">
<div className="panel-title">/STT</div> <div className="panel-title">/STT</div>
<div className="interim-box">
{interimText ? interimText : ''}
</div>
<div className="transcript-content"> <div className="transcript-content">
{transcriptLines.length === 0 && ( {transcriptLines.length === 0 && (
<div className="placeholder"> STT .</div> <div className="placeholder"> STT .</div>

View File

@@ -1,24 +1,42 @@
interface SpeechRecognitionEvent extends Event { export {}
resultIndex: number
results: SpeechRecognitionResultList
}
interface SpeechRecognition extends EventTarget { declare global {
continuous: boolean interface SpeechRecognitionEvent extends Event {
interimResults: boolean resultIndex: number
lang: string results: SpeechRecognitionResultList
onresult: ((event: SpeechRecognitionEvent) => void) | null }
onerror: ((event: Event) => void) | null
onend: (() => void) | null
start: () => void
stop: () => void
}
interface SpeechRecognitionConstructor { interface SpeechRecognitionErrorEvent extends Event {
new (): SpeechRecognition error:
} | 'no-speech'
| 'aborted'
| 'audio-capture'
| 'network'
| 'not-allowed'
| 'service-not-allowed'
| string
}
interface Window { interface SpeechRecognition extends EventTarget {
SpeechRecognition?: SpeechRecognitionConstructor continuous: boolean
webkitSpeechRecognition?: SpeechRecognitionConstructor interimResults: boolean
lang: string
maxAlternatives: number
onstart: (() => void) | null
onresult: ((event: SpeechRecognitionEvent) => void) | null
onerror: ((event: SpeechRecognitionErrorEvent) => void) | null
onend: (() => void) | null
start: () => void
stop: () => void
abort: () => void
}
interface SpeechRecognitionConstructor {
new (): SpeechRecognition
}
interface Window {
SpeechRecognition?: SpeechRecognitionConstructor
webkitSpeechRecognition?: SpeechRecognitionConstructor
}
} }

View File

@@ -16,8 +16,10 @@
"ws": "^8.19.0" "ws": "^8.19.0"
}, },
"devDependencies": { "devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/node": "^25.0.10", "@types/node": "^25.0.10",
"@types/pg": "^8.16.0",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "^5.9.3" "typescript": "^5.9.3"
@@ -486,6 +488,16 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/cors": {
"version": "2.8.19",
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
"integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/express": { "node_modules/@types/express": {
"version": "5.0.6", "version": "5.0.6",
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
@@ -528,6 +540,18 @@
"undici-types": "~7.16.0" "undici-types": "~7.16.0"
} }
}, },
"node_modules/@types/pg": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz",
"integrity": "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"pg-protocol": "*",
"pg-types": "^2.2.0"
}
},
"node_modules/@types/qs": { "node_modules/@types/qs": {
"version": "6.14.0", "version": "6.14.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",

View File

@@ -21,8 +21,10 @@
"ws": "^8.19.0" "ws": "^8.19.0"
}, },
"devDependencies": { "devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/node": "^25.0.10", "@types/node": "^25.0.10",
"@types/pg": "^8.16.0",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "^5.9.3" "typescript": "^5.9.3"

View File

@@ -3,8 +3,16 @@ set -euo pipefail
cd /home/dsyoon/workspace/meeting_ai/server cd /home/dsyoon/workspace/meeting_ai/server
PORT="${PORT:-8018}"
if lsof -ti tcp:"${PORT}" >/dev/null 2>&1; then
echo "Stopping existing server on port ${PORT}..."
lsof -ti tcp:"${PORT}" | xargs -r kill -9
sleep 1
fi
npm install npm install
npm run build npm run build
PORT="${PORT:-8018}" nohup npm run start > server.log 2>&1 & PORT="${PORT}" nohup npm run start > server.log 2>&1 &
echo "Server started (PID: $!). Logs: server.log" echo "Server started (PID: $!). Logs: server.log"

Binary file not shown.