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>
This commit is contained in:
du5t
2026-05-23 00:24:36 +09:00
commit 56e637a300
17 changed files with 2221 additions and 0 deletions

514
app/ui/app.js Normal file
View File

@@ -0,0 +1,514 @@
// ─── 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();

300
app/ui/index.html Normal file
View File

@@ -0,0 +1,300 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ASR 전사 서비스</title>
<link rel="stylesheet" href="/assets/styles.css">
</head>
<body>
<main class="wrap">
<header class="page-header">
<h1>ASR 전사 서비스</h1>
<p class="muted">파일 전사 · 실시간 전사 · 처리 내역</p>
</header>
<div class="tabs">
<button class="tab-btn active" data-tab="file">파일 전사</button>
<button class="tab-btn" data-tab="realtime">실시간 전사</button>
<button class="tab-btn" data-tab="history">처리 내역</button>
</div>
<!-- ═══════════════════ TAB: FILE ═══════════════════ -->
<div id="tab-file" class="tab-panel">
<section class="card">
<form id="file-form">
<label>오디오 / 동영상 파일
<input type="file" id="file-input" name="file" accept="audio/*,video/*" required>
</label>
<div class="grid2">
<label>백엔드
<select id="backend" name="backend">
<option value="faster-whisper" selected>faster-whisper</option>
<option value="qwen3">Qwen3-ASR</option>
</select>
</label>
<label>모델
<select id="model" name="model">
<!-- populated by JS based on backend -->
</select>
</label>
</div>
<label>커스텀 모델 경로 (선택, faster-whisper 전용)
<input type="text" id="custom_model_path" name="custom_model_path" placeholder="/srv/asr/custom-models/my-model">
</label>
<div class="grid2">
<label>언어
<input type="text" id="language" name="language" value="ko" placeholder="ko">
</label>
<label>작업
<select id="task" name="task">
<option value="transcribe" selected>transcribe</option>
<option value="translate">translate (영어로 번역)</option>
</select>
</label>
</div>
<!-- faster-whisper only -->
<div id="fw-options">
<div class="grid2">
<label>Beam size
<input type="number" id="beam_size" name="beam_size" value="5" min="1" max="20">
</label>
<label>Temperature
<input type="number" id="temperature" name="temperature" value="0" min="0" max="1" step="0.1">
</label>
</div>
<label class="inline">
<input type="checkbox" id="word_timestamps" name="word_timestamps">
단어별 타임스탬프
</label>
<div class="section-toggle">
<button type="button" class="toggle-btn" id="antirep-toggle">
반복 억제 옵션 <span class="chevron" id="antirep-chevron"></span>
</button>
</div>
<div id="antirep-panel" class="collapsible" style="display:none">
<p class="muted small">반복 토큰 문제 발생 시 조정하세요.</p>
<div class="grid2">
<label>no_repeat_ngram_size
<input type="number" id="no_repeat_ngram_size" name="no_repeat_ngram_size" value="0" min="0" max="10">
<span class="hint">0=비활성, 3~5 권장</span>
</label>
<label>repetition_penalty
<input type="number" id="repetition_penalty" name="repetition_penalty" value="1.0" min="1.0" max="2.0" step="0.05">
<span class="hint">1.0=없음, 1.1~1.3 권장</span>
</label>
<label>compression_ratio_threshold
<input type="number" id="compression_ratio_threshold" name="compression_ratio_threshold" value="2.4" min="0.5" max="5.0" step="0.1">
</label>
<label>log_prob_threshold
<input type="number" id="log_prob_threshold" name="log_prob_threshold" value="-1.0" min="-5.0" max="0.0" step="0.1">
</label>
<label>no_speech_threshold
<input type="number" id="no_speech_threshold" name="no_speech_threshold" value="0.6" min="0.0" max="1.0" step="0.05">
</label>
<label class="inline" style="align-self:end; padding-top:8px">
<input type="checkbox" id="condition_on_previous_text" name="condition_on_previous_text" checked>
이전 텍스트 조건화
</label>
</div>
</div>
<div class="divider"></div>
<label class="inline">
<input type="checkbox" id="diarize" name="diarize">
화자 분리 (Speaker Diarization)
</label>
<div id="diarize-options" style="display:none">
<div class="grid3">
<label>정확한 화자 수
<input type="number" id="num_speakers" name="num_speakers" min="1" max="30" placeholder="자동">
</label>
<label>최소 화자 수
<input type="number" id="min_speakers" name="min_speakers" min="1" max="30" placeholder="자동">
</label>
<label>최대 화자 수
<input type="number" id="max_speakers" name="max_speakers" min="1" max="30" placeholder="자동">
</label>
</div>
</div>
</div>
<div class="actions">
<button type="submit" id="file-submit-btn">전사 실행</button>
<button type="button" id="file-copy-btn">텍스트 복사</button>
<button type="button" id="file-dl-txt-btn">TXT 저장</button>
<button type="button" id="file-dl-json-btn">JSON 저장</button>
</div>
</form>
</section>
<section class="card">
<h2>상태</h2>
<pre id="file-status">대기 중</pre>
</section>
<section class="card" id="file-speaker-section" style="display:none">
<h2>화자별 결과</h2>
<div id="file-speaker-result"></div>
</section>
<section class="card">
<h2>텍스트 결과</h2>
<textarea id="file-result-text" rows="12" placeholder="전사 결과가 여기에 표시됩니다."></textarea>
</section>
<section class="card">
<h2>JSON 결과</h2>
<pre id="file-result-json" class="result-json">{}</pre>
</section>
</div>
<!-- ═══════════════════ TAB: REALTIME ═══════════════════ -->
<div id="tab-realtime" class="tab-panel" style="display:none">
<section class="card">
<h2>실시간 전사 설정</h2>
<div class="grid2">
<label>모델
<select id="rt-model">
<option value="tiny">tiny (가장 빠름)</option>
<option value="base">base</option>
<option value="small">small</option>
<option value="medium">medium</option>
<option value="large-v3" selected>large-v3</option>
</select>
</label>
<label>언어
<input type="text" id="rt-language" value="ko" placeholder="ko">
</label>
</div>
<div class="grid2">
<label>Beam size
<input type="number" id="rt-beam_size" value="3" min="1" max="10">
</label>
<label>청크 처리 간격
<select id="rt-chunk_seconds">
<option value="2">2초 (빠른 반응)</option>
<option value="3" selected>3초 (균형)</option>
<option value="5">5초 (더 정확)</option>
</select>
</label>
</div>
<div class="section-toggle">
<button type="button" class="toggle-btn" id="rt-antirep-toggle">
반복 억제 옵션 <span class="chevron" id="rt-antirep-chevron"></span>
</button>
</div>
<div id="rt-antirep-panel" class="collapsible" style="display:none">
<div class="grid2">
<label>no_repeat_ngram_size
<input type="number" id="rt-no_repeat_ngram_size" value="0" min="0" max="10">
<span class="hint">0=비활성, 3~5 권장</span>
</label>
<label>repetition_penalty
<input type="number" id="rt-repetition_penalty" value="1.0" min="1.0" max="2.0" step="0.05">
</label>
<label>compression_ratio_threshold
<input type="number" id="rt-compression_ratio_threshold" value="2.4" min="0.5" max="5.0" step="0.1">
</label>
<label>no_speech_threshold
<input type="number" id="rt-no_speech_threshold" value="0.6" min="0.0" max="1.0" step="0.05">
</label>
</div>
<label class="inline">
<input type="checkbox" id="rt-condition_on_previous_text" checked>
이전 텍스트 조건화
</label>
</div>
</section>
<section class="card">
<div class="mic-row">
<button id="rt-start-btn" class="mic-btn">&#9679; 녹음 시작</button>
<button id="rt-stop-btn" class="mic-btn stop-btn" disabled>&#9632; 녹음 중지</button>
</div>
<div id="rt-indicator" class="rt-indicator idle">대기 중</div>
</section>
<section class="card">
<h2>실시간 전사</h2>
<div id="rt-live-box" class="live-box">
<span class="muted">녹음을 시작하면 텍스트가 여기에 나타납니다.</span>
</div>
</section>
<section class="card">
<h2>최종 텍스트</h2>
<textarea id="rt-final-text" rows="10" placeholder="녹음 종료 후 최종 결과가 표시됩니다."></textarea>
<div class="actions" style="margin-top:10px">
<button type="button" id="rt-copy-btn">텍스트 복사</button>
<button type="button" id="rt-dl-txt-btn">TXT 저장</button>
</div>
</section>
</div>
<!-- ═══════════════════ TAB: HISTORY ═══════════════════ -->
<div id="tab-history" class="tab-panel" style="display:none">
<section class="card">
<div class="history-toolbar">
<h2 style="margin:0">처리 내역</h2>
<button type="button" id="history-refresh-btn" class="btn-secondary">새로고침</button>
</div>
</section>
<div id="history-list">
<div class="muted" style="padding:16px">내역을 불러오는 중...</div>
</div>
<!-- 상세 뷰 -->
<div id="history-detail" style="display:none">
<section class="card">
<div class="detail-header">
<button type="button" id="history-back-btn" class="btn-secondary">← 목록으로</button>
<span id="detail-title" class="detail-title"></span>
<button type="button" id="detail-delete-btn" class="btn-danger">삭제</button>
</div>
</section>
<section class="card" id="detail-media-section" style="display:none">
<h2>원본 파일</h2>
<div id="detail-media-container"></div>
</section>
<section class="card">
<h2>정보</h2>
<div id="detail-meta" class="meta-grid"></div>
</section>
<section class="card" id="detail-speaker-section" style="display:none">
<h2>화자별 결과</h2>
<div id="detail-speaker-result"></div>
</section>
<section class="card">
<h2>텍스트 결과</h2>
<textarea id="detail-text" rows="12" readonly></textarea>
<div class="actions" style="margin-top:10px">
<button type="button" id="detail-copy-btn">텍스트 복사</button>
<button type="button" id="detail-dl-txt-btn">TXT 저장</button>
<button type="button" id="detail-dl-json-btn">JSON 저장</button>
</div>
</section>
<section class="card">
<h2>JSON 결과</h2>
<pre id="detail-json" class="result-json">{}</pre>
</section>
</div>
</div>
</main>
<script src="/assets/app.js"></script>
</body>
</html>

29
app/ui/pcm-processor.js Normal file
View File

@@ -0,0 +1,29 @@
// AudioWorklet processor — accumulates 4096 samples (~256ms at 16kHz) before sending
class PCMProcessor extends AudioWorkletProcessor {
constructor() {
super();
this._chunks = [];
this._total = 0;
this._target = 4096;
}
process(inputs) {
const ch = inputs[0] && inputs[0][0];
if (ch && ch.length > 0) {
this._chunks.push(ch.slice());
this._total += ch.length;
if (this._total >= this._target) {
const out = new Float32Array(this._total);
let offset = 0;
for (const c of this._chunks) { out.set(c, offset); offset += c.length; }
this._chunks = [];
this._total = 0;
this.port.postMessage(out.buffer, [out.buffer]);
}
}
return true;
}
}
registerProcessor('pcm-processor', PCMProcessor);

293
app/ui/styles.css Normal file
View File

@@ -0,0 +1,293 @@
* { box-sizing: border-box; }
body {
margin: 0;
font-family: Arial, sans-serif;
background: #0b1020;
color: #e7ebf3;
}
.wrap { max-width: 1100px; margin: 0 auto; padding: 24px; }
.page-header { margin-bottom: 8px; }
.page-header h1 { margin: 0 0 4px; }
.muted { color: #aab3cf; margin: 0; }
.small { font-size: 0.87em; }
.mono { font-family: monospace; }
.hint { display: block; color: #7a85a8; font-size: 0.78em; margin-top: 3px; font-weight: 400; }
/* Tabs */
.tabs {
display: flex;
gap: 8px;
margin-bottom: 20px;
border-bottom: 1px solid #273056;
padding-bottom: 0;
}
.tab-btn {
background: transparent;
border: none;
color: #7a85a8;
padding: 10px 20px;
font-size: 1em;
cursor: pointer;
border-bottom: 3px solid transparent;
border-radius: 0;
margin-bottom: -1px;
transition: color 0.15s, border-color 0.15s;
}
.tab-btn:hover { color: #e7ebf3; }
.tab-btn.active { color: #e7ebf3; border-bottom-color: #3d63ff; }
/* Card */
.card {
background: #121933;
border: 1px solid #273056;
border-radius: 16px;
padding: 20px;
margin-bottom: 20px;
}
.card h2 { margin-top: 0; font-size: 1.05em; }
/* Form elements */
label {
display: block;
margin-bottom: 14px;
font-weight: 600;
font-size: 0.93em;
}
input[type="text"],
input[type="number"],
select,
textarea,
input[type="file"] {
width: 100%;
margin-top: 5px;
padding: 9px 12px;
border: 1px solid #3a446d;
border-radius: 10px;
background: #0f1530;
color: #e7ebf3;
font-size: 0.95em;
}
textarea { width: 100%; resize: vertical; }
.inline {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
font-weight: 500;
}
.inline input[type="checkbox"] { width: auto; margin-top: 0; }
/* Grids */
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.grid3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 14px; }
/* Buttons */
.actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 14px; }
button {
border: 0;
border-radius: 10px;
padding: 10px 16px;
background: #3d63ff;
color: white;
cursor: pointer;
font-size: 0.93em;
}
button:hover:not(:disabled) { opacity: 0.88; }
button:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-secondary {
background: #1a2244;
color: #aab3cf;
border: 1px solid #3a446d;
}
.btn-secondary:hover:not(:disabled) { color: #e7ebf3; opacity: 1; background: #222d55; }
.btn-danger { background: #7a1e1e; }
.btn-danger:hover:not(:disabled) { background: #9e2626; opacity: 1; }
/* Section toggle */
.section-toggle { margin: 10px 0 0; }
.toggle-btn {
background: #1a2244;
color: #aab3cf;
padding: 7px 14px;
font-size: 0.88em;
border: 1px solid #3a446d;
}
.toggle-btn:hover { color: #e7ebf3; }
.chevron { display: inline-block; transition: transform 0.2s; }
.collapsible { margin-top: 12px; padding: 14px; background: #0f1530; border: 1px solid #273056; border-radius: 10px; }
/* Divider */
.divider { border-top: 1px solid #273056; margin: 14px 0; }
/* Badges */
.badge-green {
background: #1a4a2a;
color: #5cd68a;
border-radius: 6px;
padding: 2px 8px;
font-size: 0.78em;
font-weight: 600;
margin-left: 6px;
}
.badge-backend {
background: #1a2a4a;
color: #5ab4e0;
border-radius: 6px;
padding: 2px 8px;
font-size: 0.8em;
font-weight: 600;
}
/* Pre / JSON */
pre {
background: #0f1530;
border: 1px solid #3a446d;
border-radius: 10px;
padding: 12px;
white-space: pre-wrap;
word-break: break-word;
overflow: auto;
margin: 0;
font-size: 0.88em;
}
.result-json { max-height: 400px; }
/* Speaker blocks */
.speaker-block {
margin-bottom: 12px;
padding: 12px 14px;
background: #0f1530;
border: 1px solid #3a446d;
border-radius: 10px;
}
.speaker-badge {
display: flex;
align-items: center;
gap: 8px;
font-weight: 700;
font-size: 0.9em;
margin-bottom: 6px;
}
.speaker-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.speaker-time { color: #7a85a8; font-weight: 400; font-size: 0.85em; }
.speaker-text { color: #e7ebf3; line-height: 1.7; }
/* Realtime */
.mic-row { display: flex; gap: 12px; margin-bottom: 12px; }
.mic-btn { font-size: 1em; padding: 12px 24px; border-radius: 12px; background: #3d63ff; }
.stop-btn { background: #c0392b; }
.rt-indicator {
font-size: 0.88em;
padding: 6px 12px;
border-radius: 8px;
display: inline-block;
font-weight: 600;
}
.rt-indicator.idle { background: #1a2244; color: #7a85a8; }
.rt-indicator.recording { background: #3a1a1a; color: #e05a5a; animation: blink 1.2s infinite; }
.rt-indicator.processing { background: #2a2a1a; color: #e0c44f; }
@keyframes blink { 0%,100% { opacity:1; } 50% { opacity:0.5; } }
.live-box {
min-height: 120px;
padding: 14px;
background: #0f1530;
border: 1px solid #3a446d;
border-radius: 10px;
line-height: 1.75;
white-space: pre-wrap;
word-break: break-word;
font-size: 0.97em;
}
.live-partial { color: #aab3cf; }
.live-confirmed { color: #e7ebf3; }
/* History */
.history-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
}
.history-table-wrap {
overflow-x: auto;
padding: 0 20px 20px;
}
.history-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9em;
}
.history-table th {
text-align: left;
padding: 10px 12px;
border-bottom: 1px solid #273056;
color: #7a85a8;
font-weight: 600;
white-space: nowrap;
}
.history-table td {
padding: 10px 12px;
border-bottom: 1px solid #1a2244;
vertical-align: top;
}
.history-row:hover td { background: #131c38; }
.preview-cell {
max-width: 280px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #aab3cf;
}
.btn-view {
background: #1a2a4a;
color: #5ab4e0;
border: 1px solid #3a446d;
padding: 5px 12px;
font-size: 0.85em;
border-radius: 8px;
white-space: nowrap;
}
.btn-view:hover { background: #1e3560; opacity: 1; }
/* Detail view */
.detail-header {
display: flex;
align-items: center;
gap: 12px;
}
.detail-title {
flex: 1;
font-weight: 700;
font-size: 1em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.meta-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 10px;
}
.meta-item {
background: #0f1530;
border: 1px solid #273056;
border-radius: 10px;
padding: 10px 14px;
}
.meta-key { display: block; color: #7a85a8; font-size: 0.78em; font-weight: 600; margin-bottom: 4px; }
.meta-val { display: block; font-size: 0.95em; word-break: break-all; }
/* Media player */
.media-player {
width: 100%;
border-radius: 10px;
background: #0f1530;
}
@media (max-width: 720px) {
.grid2, .grid3 { grid-template-columns: 1fr; }
.mic-row { flex-direction: column; }
.detail-header { flex-wrap: wrap; }
.history-table th:nth-child(4),
.history-table td:nth-child(4) { display: none; }
}