Restructure into asr/tts packages and add TTS tab skeleton
Split the single ASR gateway into app/main.py (entrypoint) + app/core (shared env helpers) + app/asr (all existing ASR logic, unchanged behavior) + app/tts (placeholder router/config, no engine yet), so a real TTS backend can be dropped in later without reshuffling ASR code. API moved under /asr/* and /tts/* prefixes; UI gained a top-level ASR/TTS tab switcher built on a reusable nested .tab-group mechanism, with JS split into common.js/asr.js/tts.js. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
407
app/ui/assets/js/asr.js
Normal file
407
app/ui/assets/js/asr.js
Normal file
@@ -0,0 +1,407 @@
|
||||
// ─── 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 ? `<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}/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 = '<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('/asr/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(`/asr/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 = `/asr/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(`/asr/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');
|
||||
});
|
||||
|
||||
// Refresh history whenever its tab is opened (tab show/hide itself is handled by common.js)
|
||||
document.querySelector('[data-target="tab-history"]').addEventListener('click', loadHistory);
|
||||
|
||||
// ─── Init ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function initAsrUI() {
|
||||
populateModels(backendSel.value);
|
||||
makeToggle('antirep-toggle', 'antirep-panel', 'antirep-chevron');
|
||||
makeToggle('rt-antirep-toggle', 'rt-antirep-panel', 'rt-antirep-chevron');
|
||||
}
|
||||
|
||||
loadConfig();
|
||||
Reference in New Issue
Block a user