// ─── Config (fetched from /config) ─────────────────────────────────────────── let SERVER_CONFIG = null; async function loadConfig() { try { const r = await fetch('/config'); SERVER_CONFIG = await r.json(); } catch (e) { SERVER_CONFIG = { default_backend: 'faster-whisper', default_model: 'large-v3', default_language: 'ko', backends: { 'faster-whisper': { models: ['tiny','base','small','medium','large-v3','large-v2','turbo'] }, 'qwen3': { models: ['Qwen/Qwen3-ASR-2B','Qwen/Qwen3-ASR-8B'] }, }, }; } initUI(); } // ─── Utilities ─────────────────────────────────────────────────────────────── const SPEAKER_COLORS = [ '#4fa3e0','#e06b4f','#4fe09c','#e0c44f','#a04fe0', '#e04fa3','#4fd4e0','#e0a44f','#7fe04f','#9b59b6', ]; const colorMap = {}; let colorIdx = 0; function speakerColor(speaker) { if (!(speaker in colorMap)) { colorMap[speaker] = speaker === 'UNKNOWN' ? '#7a85a8' : SPEAKER_COLORS[colorIdx++ % SPEAKER_COLORS.length]; } return colorMap[speaker]; } function resetColors() { Object.keys(colorMap).forEach(k => delete colorMap[k]); colorIdx = 0; } function esc(text) { const d = document.createElement('div'); d.appendChild(document.createTextNode(text)); return d.innerHTML; } function fmtTime(s) { const m = Math.floor(s / 60); const sec = (s % 60).toFixed(1).padStart(4, '0'); return `${String(m).padStart(2,'0')}:${sec}`; } function fmtTimestamp(id) { // id: YYYYMMDD_HHMMSS_ffffff if (!id || id.length < 15) return id; const y = id.slice(0,4), mo = id.slice(4,6), d = id.slice(6,8); const h = id.slice(9,11), mi = id.slice(11,13), s = id.slice(13,15); return `${y}-${mo}-${d} ${h}:${mi}:${s}`; } function renderSpeakerBlocks(segments, container, sectionEl) { if (!segments || !segments.length || !segments.some(s => s.speaker)) { sectionEl.style.display = 'none'; return; } resetColors(); const groups = []; let cur = null; for (const seg of segments) { const spk = seg.speaker || 'UNKNOWN'; if (!cur || cur.speaker !== spk) { cur = {speaker: spk, segs: [seg]}; groups.push(cur); } else cur.segs.push(seg); } let html = ''; for (const g of groups) { const c = speakerColor(g.speaker); const text = g.segs.map(s => (s.text||'').trim()).join(' '); const t0 = g.segs[0].start, t1 = g.segs[g.segs.length-1].end; html += `
${esc(g.speaker)} ${fmtTime(t0)} – ${fmtTime(t1)}
${esc(text)}
`; } container.innerHTML = html; sectionEl.style.display = ''; } function downloadBlob(name, text, type) { const blob = new Blob([text], {type}); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = name; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); } // ─── Tabs ──────────────────────────────────────────────────────────────────── document.querySelectorAll('.tab-btn').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); document.querySelectorAll('.tab-panel').forEach(p => p.style.display = 'none'); btn.classList.add('active'); const panel = document.getElementById('tab-' + btn.dataset.tab); panel.style.display = ''; if (btn.dataset.tab === 'history') loadHistory(); }); }); // ─── Collapsible toggles ────────────────────────────────────────────────────── function makeToggle(btnId, panelId, chevronId) { const btn = document.getElementById(btnId); const panel = document.getElementById(panelId); const chevron = document.getElementById(chevronId); if (!btn || !panel) return; btn.addEventListener('click', () => { const open = panel.style.display !== 'none'; panel.style.display = open ? 'none' : ''; if (chevron) chevron.style.transform = open ? '' : 'rotate(90deg)'; }); } // ─── FILE TAB ──────────────────────────────────────────────────────────────── const backendSel = document.getElementById('backend'); const modelSel = document.getElementById('model'); const fwOptions = document.getElementById('fw-options'); const diarizeChk = document.getElementById('diarize'); const diarizeOpts = document.getElementById('diarize-options'); const fileStatus = document.getElementById('file-status'); const fileResultText = document.getElementById('file-result-text'); const fileResultJson = document.getElementById('file-result-json'); const fileSpeakerSection = document.getElementById('file-speaker-section'); const fileSpeakerResult = document.getElementById('file-speaker-result'); let fileLastPayload = null; function populateModels(backend) { if (!SERVER_CONFIG) return; const models = SERVER_CONFIG.backends[backend]?.models || []; const defaultModel = SERVER_CONFIG.default_model; modelSel.innerHTML = ''; for (const m of models) { const opt = document.createElement('option'); opt.value = m; opt.textContent = m; if (m === defaultModel) opt.selected = true; modelSel.appendChild(opt); } // If no default matched, select first if (!modelSel.value && models.length) modelSel.value = models[0]; } function onBackendChange() { const isQwen = backendSel.value === 'qwen3'; fwOptions.style.display = isQwen ? 'none' : ''; populateModels(backendSel.value); } backendSel.addEventListener('change', onBackendChange); diarizeChk.addEventListener('change', () => { diarizeOpts.style.display = diarizeChk.checked ? '' : 'none'; if (!diarizeChk.checked) { ['num_speakers','min_speakers','max_speakers'].forEach(id => { document.getElementById(id).value = ''; }); } }); document.getElementById('file-form').addEventListener('submit', async (e) => { e.preventDefault(); const fi = document.getElementById('file-input'); if (!fi.files.length) { fileStatus.textContent = '파일을 선택하세요.'; return; } const fd = new FormData(document.getElementById('file-form')); fd.set('file', fi.files[0]); ['num_speakers','min_speakers','max_speakers'].forEach(n => { if (!fd.get(n)) fd.delete(n); }); fd.set('word_timestamps', document.getElementById('word_timestamps').checked ? 'true' : 'false'); fd.set('diarize', diarizeChk.checked ? 'true' : 'false'); fd.set('condition_on_previous_text', document.getElementById('condition_on_previous_text').checked ? 'true' : 'false'); fileStatus.textContent = '전사 요청 중...'; fileResultText.value = ''; fileResultJson.textContent = '{}'; fileSpeakerSection.style.display = 'none'; fileSpeakerResult.innerHTML = ''; document.getElementById('file-submit-btn').disabled = true; try { const resp = await fetch('/transcribe', {method:'POST', body: fd}); const payload = await resp.json(); if (!resp.ok) throw new Error(payload.detail || JSON.stringify(payload)); fileLastPayload = payload; fileResultText.value = payload.text || ''; fileResultJson.textContent = JSON.stringify(payload, null, 2); renderSpeakerBlocks(payload.segments, fileSpeakerResult, fileSpeakerSection); const lang = payload.language ? ` [${payload.language}]` : ''; const dur = payload.duration ? ` ${payload.duration.toFixed(1)}s` : ''; fileStatus.textContent = `완료${lang}${dur}` + (payload.diarized ? ' — 화자분리 적용' : ''); } catch (err) { fileStatus.textContent = `실패: ${err.message}`; } finally { document.getElementById('file-submit-btn').disabled = false; } }); document.getElementById('file-copy-btn').addEventListener('click', async () => { try { await navigator.clipboard.writeText(fileResultText.value || ''); fileStatus.textContent = '텍스트를 복사했습니다.'; } catch (err) { fileStatus.textContent = `복사 실패: ${err.message}`; } }); document.getElementById('file-dl-txt-btn').addEventListener('click', () => { downloadBlob('transcript.txt', fileResultText.value || '', 'text/plain;charset=utf-8'); }); document.getElementById('file-dl-json-btn').addEventListener('click', () => { downloadBlob('transcript.json', JSON.stringify(fileLastPayload||{},null,2), 'application/json;charset=utf-8'); }); // ─── REALTIME TAB ──────────────────────────────────────────────────────────── const rtStartBtn = document.getElementById('rt-start-btn'); const rtStopBtn = document.getElementById('rt-stop-btn'); const rtIndicator = document.getElementById('rt-indicator'); const rtLiveBox = document.getElementById('rt-live-box'); const rtFinalText = document.getElementById('rt-final-text'); let ws = null, audioCtx = null, mediaStream = null; let workletNode = null, sourceNode = null; let confirmedText = '', isRecording = false; function setIndicator(state, text) { rtIndicator.className = 'rt-indicator ' + state; rtIndicator.textContent = text; } function appendLive(partial) { rtLiveBox.innerHTML = (confirmedText ? `${esc(confirmedText)}\n` : '') + (partial ? `${esc(partial)}` : ''); rtLiveBox.scrollTop = rtLiveBox.scrollHeight; } async function startRecording() { if (isRecording) return; confirmedText = ''; rtLiveBox.innerHTML = '연결 중...'; rtFinalText.value = ''; rtStartBtn.disabled = true; rtStopBtn.disabled = true; const cfg = { model: document.getElementById('rt-model').value, language: document.getElementById('rt-language').value.trim() || 'ko', beam_size: parseInt(document.getElementById('rt-beam_size').value), chunk_seconds: parseInt(document.getElementById('rt-chunk_seconds').value), no_repeat_ngram_size: parseInt(document.getElementById('rt-no_repeat_ngram_size').value), repetition_penalty: parseFloat(document.getElementById('rt-repetition_penalty').value), compression_ratio_threshold: parseFloat(document.getElementById('rt-compression_ratio_threshold').value), no_speech_threshold: parseFloat(document.getElementById('rt-no_speech_threshold').value), condition_on_previous_text: document.getElementById('rt-condition_on_previous_text').checked, }; const proto = location.protocol === 'https:' ? 'wss' : 'ws'; ws = new WebSocket(`${proto}://${location.host}/ws/realtime`); ws.binaryType = 'arraybuffer'; ws.onopen = () => ws.send(JSON.stringify(cfg)); ws.onmessage = async (ev) => { const msg = JSON.parse(ev.data); if (msg.type === 'ready') { try { mediaStream = await navigator.mediaDevices.getUserMedia({audio: {channelCount:1, sampleRate:16000}}); } catch (err) { setIndicator('idle', `마이크 오류: ${err.message}`); ws.close(); return; } audioCtx = new AudioContext({sampleRate: 16000}); await audioCtx.audioWorklet.addModule('/assets/pcm-processor.js'); sourceNode = audioCtx.createMediaStreamSource(mediaStream); workletNode = new AudioWorkletNode(audioCtx, 'pcm-processor'); workletNode.port.onmessage = (e) => { if (ws && ws.readyState === WebSocket.OPEN) ws.send(e.data); }; sourceNode.connect(workletNode); workletNode.connect(audioCtx.destination); isRecording = true; rtStartBtn.disabled = true; rtStopBtn.disabled = false; setIndicator('recording', '녹음 중...'); rtLiveBox.innerHTML = '음성을 인식하는 중...'; } else if (msg.type === 'partial') { if (msg.text) confirmedText = (confirmedText ? confirmedText + ' ' : '') + msg.text; appendLive(''); } else if (msg.type === 'final') { rtFinalText.value = msg.text || ''; setIndicator('idle', '완료'); cleanupAudio(); } else if (msg.type === 'error') { setIndicator('idle', `오류: ${msg.detail}`); cleanupAudio(); } }; ws.onerror = () => { setIndicator('idle', 'WebSocket 오류'); cleanupAudio(); }; ws.onclose = () => { if (isRecording) { isRecording = false; cleanupAudio(); } }; } function cleanupAudio() { isRecording = false; if (workletNode) { try { workletNode.disconnect(); } catch(e){} workletNode = null; } if (sourceNode) { try { sourceNode.disconnect(); } catch(e){} sourceNode = null; } if (mediaStream) { mediaStream.getTracks().forEach(t => t.stop()); mediaStream = null; } if (audioCtx) { try { audioCtx.close(); } catch(e){} audioCtx = null; } rtStartBtn.disabled = false; rtStopBtn.disabled = true; } function stopRecording() { if (!isRecording) return; setIndicator('processing', '처리 중...'); rtStopBtn.disabled = true; if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({cmd: 'stop'})); if (workletNode) { try { workletNode.disconnect(); } catch(e){} workletNode = null; } if (sourceNode) { try { sourceNode.disconnect(); } catch(e){} sourceNode = null; } if (mediaStream) { mediaStream.getTracks().forEach(t => t.stop()); mediaStream = null; } if (audioCtx) { try { audioCtx.close(); } catch(e){} audioCtx = null; } isRecording = false; } rtStartBtn.addEventListener('click', startRecording); rtStopBtn.addEventListener('click', stopRecording); document.getElementById('rt-copy-btn').addEventListener('click', async () => { try { await navigator.clipboard.writeText(rtFinalText.value || ''); } catch (e) { console.error(e); } }); document.getElementById('rt-dl-txt-btn').addEventListener('click', () => { downloadBlob('realtime_transcript.txt', rtFinalText.value || '', 'text/plain;charset=utf-8'); }); // ─── HISTORY TAB ───────────────────────────────────────────────────────────── const historyList = document.getElementById('history-list'); const historyDetail = document.getElementById('history-detail'); let currentRecord = null; async function loadHistory() { historyList.innerHTML = '
불러오는 중...
'; historyDetail.style.display = 'none'; historyList.style.display = ''; try { const r = await fetch('/history'); const records = await r.json(); renderHistoryList(records); } catch (e) { historyList.innerHTML = `
오류: ${esc(e.message)}
`; } } function renderHistoryList(records) { if (!records.length) { historyList.innerHTML = '
처리 내역이 없습니다.
'; return; } let html = '
' + '' + ''; for (const rec of records) { const dur = rec.duration != null ? `${rec.duration.toFixed(1)}s` : '-'; const dia = rec.diarized ? ' 화자분리' : ''; html += ``; } html += '
시각파일명백엔드모델언어길이미리보기
${esc(fmtTimestamp(rec.id))} ${esc(rec.filename || '-')} ${esc(rec.backend || '-')} ${esc(rec.model || '-')} ${esc(rec.language || '-')} ${dur} ${esc(rec.text_preview || '')}${dia}
'; historyList.innerHTML = html; historyList.querySelectorAll('.btn-view').forEach(btn => { btn.addEventListener('click', () => openHistoryDetail(btn.dataset.id)); }); } async function openHistoryDetail(id) { try { const r = await fetch(`/history/${id}`); if (!r.ok) throw new Error(await r.text()); currentRecord = await r.json(); currentRecord._id = id; renderDetail(currentRecord); historyList.style.display = 'none'; historyDetail.style.display = ''; } catch (e) { alert(`불러오기 실패: ${e.message}`); } } function renderDetail(data) { document.getElementById('detail-title').textContent = data._filename || fmtTimestamp(data._id || ''); // Meta info const meta = document.getElementById('detail-meta'); const fields = [ ['백엔드', data.gateway_backend || data.backend || '-'], ['모델', data.model || '-'], ['언어', data.language || '-'], ['길이', data.duration != null ? `${data.duration.toFixed(1)}s` : '-'], ['화자분리', data.diarized ? '예' : '아니오'], ['처리시각', fmtTimestamp(data._timestamp || data._id || '')], ]; meta.innerHTML = fields.map(([k,v]) => `
${esc(k)}${esc(v)}
` ).join(''); // Media player const mediaSection = document.getElementById('detail-media-section'); const mediaContainer = document.getElementById('detail-media-container'); const uploadPath = data.upload_file || ''; const uploadName = uploadPath ? uploadPath.split('/').pop() : ''; if (uploadName) { const url = `/uploads/${encodeURIComponent(uploadName)}`; const ext = uploadName.split('.').pop().toLowerCase(); const videoExts = ['mp4','mkv','webm','avi','mov','m4v']; if (videoExts.includes(ext)) { mediaContainer.innerHTML = ``; } else { mediaContainer.innerHTML = ``; } mediaSection.style.display = ''; } else { mediaSection.style.display = 'none'; } // Text + JSON document.getElementById('detail-text').value = data.text || ''; document.getElementById('detail-json').textContent = JSON.stringify(data, null, 2); // Speaker blocks renderSpeakerBlocks( data.segments, document.getElementById('detail-speaker-result'), document.getElementById('detail-speaker-section'), ); } document.getElementById('history-back-btn').addEventListener('click', () => { historyDetail.style.display = 'none'; historyList.style.display = ''; }); document.getElementById('history-refresh-btn').addEventListener('click', loadHistory); document.getElementById('detail-delete-btn').addEventListener('click', async () => { if (!currentRecord || !currentRecord._id) return; if (!confirm('이 내역을 삭제하시겠습니까? 원본 파일도 함께 삭제됩니다.')) return; try { const r = await fetch(`/history/${currentRecord._id}`, {method: 'DELETE'}); if (!r.ok) throw new Error(await r.text()); historyDetail.style.display = 'none'; historyList.style.display = ''; await loadHistory(); } catch (e) { alert(`삭제 실패: ${e.message}`); } }); document.getElementById('detail-copy-btn').addEventListener('click', async () => { try { await navigator.clipboard.writeText(document.getElementById('detail-text').value || ''); } catch (e) { console.error(e); } }); document.getElementById('detail-dl-txt-btn').addEventListener('click', () => { const fname = (currentRecord?._filename || 'transcript').replace(/\.[^.]+$/, '') + '.txt'; downloadBlob(fname, document.getElementById('detail-text').value || '', 'text/plain;charset=utf-8'); }); document.getElementById('detail-dl-json-btn').addEventListener('click', () => { const fname = (currentRecord?._filename || 'transcript').replace(/\.[^.]+$/, '') + '.json'; downloadBlob(fname, JSON.stringify(currentRecord||{}, null, 2), 'application/json;charset=utf-8'); }); // ─── Init ───────────────────────────────────────────────────────────────────── function initUI() { populateModels(backendSel.value); makeToggle('antirep-toggle', 'antirep-panel', 'antirep-chevron'); makeToggle('rt-antirep-toggle', 'rt-antirep-panel', 'rt-antirep-chevron'); } loadConfig();