// ─── Config (fetched from /asr/config) ──────────────────────────────────────── let SERVER_CONFIG = null; async function loadConfig() { try { const r = await fetch('/asr/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'] }, }, }; } initAsrUI(); } // ─── 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('/asr/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}/asr/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/js/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', msg.id ? '완료 (처리 내역에 저장됨)' : '완료'); 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 = '
| 시각 | 파일명 | 백엔드 | 모델 | 언어 | 길이 | 미리보기 | ' + ' |
|---|---|---|---|---|---|---|---|
| ${esc(fmtTimestamp(rec.id))} | ${esc(rec.filename || '-')} | ${esc(rec.backend || '-')} | ${esc(rec.model || '-')} | ${esc(rec.language || '-')} | ${dur} | ${esc(rec.text_preview || '')}${dia} |