Files
speech/app/ui/app.js
du5t 56e637a300 Initial commit: ASR v2 with multi-venv isolation
- faster-whisper, Qwen3-ASR, gateway 각 컴포넌트별 Python venv 분리
- 기본언어 한국어(ko)
- 처리내역 탭: 목록/상세/원본파일 재생/삭제
- 백엔드별 동적 모델 드랍다운
- /history, /uploads API 추가
- 기존 인스턴스(port 18100) 보존, 신규 port 18101

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 00:24:36 +09:00

515 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ─── 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 += `<div class="speaker-block">
<div class="speaker-badge">
<span class="speaker-dot" style="background:${c}"></span>
<span style="color:${c}">${esc(g.speaker)}</span>
<span class="speaker-time">${fmtTime(t0)} ${fmtTime(t1)}</span>
</div>
<div class="speaker-text">${esc(text)}</div>
</div>`;
}
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 ? `<span class="live-confirmed">${esc(confirmedText)}</span>\n` : '') +
(partial ? `<span class="live-partial">${esc(partial)}</span>` : '');
rtLiveBox.scrollTop = rtLiveBox.scrollHeight;
}
async function startRecording() {
if (isRecording) return;
confirmedText = '';
rtLiveBox.innerHTML = '<span class="muted">연결 중...</span>';
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 = '<span class="muted">음성을 인식하는 중...</span>';
} 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 = '<div class="muted" style="padding:16px">불러오는 중...</div>';
historyDetail.style.display = 'none';
historyList.style.display = '';
try {
const r = await fetch('/history');
const records = await r.json();
renderHistoryList(records);
} catch (e) {
historyList.innerHTML = `<div class="muted" style="padding:16px">오류: ${esc(e.message)}</div>`;
}
}
function renderHistoryList(records) {
if (!records.length) {
historyList.innerHTML = '<div class="muted" style="padding:16px">처리 내역이 없습니다.</div>';
return;
}
let html = '<div class="history-table-wrap"><table class="history-table"><thead><tr>'
+ '<th>시각</th><th>파일명</th><th>백엔드</th><th>모델</th><th>언어</th><th>길이</th><th>미리보기</th><th></th>'
+ '</tr></thead><tbody>';
for (const rec of records) {
const dur = rec.duration != null ? `${rec.duration.toFixed(1)}s` : '-';
const dia = rec.diarized ? ' <span class="badge-green">화자분리</span>' : '';
html += `<tr class="history-row" data-id="${esc(rec.id)}">
<td class="mono">${esc(fmtTimestamp(rec.id))}</td>
<td>${esc(rec.filename || '-')}</td>
<td><span class="badge-backend">${esc(rec.backend || '-')}</span></td>
<td class="mono small">${esc(rec.model || '-')}</td>
<td>${esc(rec.language || '-')}</td>
<td>${dur}</td>
<td class="preview-cell">${esc(rec.text_preview || '')}${dia}</td>
<td><button class="btn-view" data-id="${esc(rec.id)}">보기</button></td>
</tr>`;
}
html += '</tbody></table></div>';
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]) =>
`<div class="meta-item"><span class="meta-key">${esc(k)}</span><span class="meta-val">${esc(v)}</span></div>`
).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 = `<video controls class="media-player"><source src="${url}"><p>미리보기 불가</p></video>`;
} else {
mediaContainer.innerHTML = `<audio controls class="media-player"><source src="${url}"><p>미리보기 불가</p></audio>`;
}
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();