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:
du5t
2026-06-18 11:28:43 +09:00
parent ab6df14044
commit cabbdac39b
18 changed files with 592 additions and 463 deletions

0
app/asr/__init__.py Normal file
View File

View File

@@ -1,34 +1,9 @@
from __future__ import annotations from __future__ import annotations
import os
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
from core.config import env_str
def env_str(name: str, default: str = "") -> str:
return os.getenv(name, default)
def env_int(name: str, default: int) -> int:
raw = os.getenv(name)
if raw is None or raw == "":
return default
return int(raw)
def env_float(name: str, default: float) -> float:
raw = os.getenv(name)
if raw is None or raw == "":
return default
return float(raw)
def env_bool(name: str, default: bool = False) -> bool:
raw = os.getenv(name)
if raw is None:
return default
return raw.strip().lower() in {"1", "true", "yes", "on"}
ASR_BASE_DIR = Path(env_str("ASR_BASE_DIR", "/srv/asr")) ASR_BASE_DIR = Path(env_str("ASR_BASE_DIR", "/srv/asr"))
MODEL_CACHE = Path(env_str("WHISPER_CACHE_DIR", str(ASR_BASE_DIR / "models-cache"))) MODEL_CACHE = Path(env_str("WHISPER_CACHE_DIR", str(ASR_BASE_DIR / "models-cache")))

View File

@@ -9,13 +9,10 @@ from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
import httpx import httpx
import uvicorn from fastapi import APIRouter, File, Form, HTTPException, UploadFile, WebSocket, WebSocketDisconnect
from fastapi import FastAPI, File, Form, HTTPException, UploadFile, WebSocket, WebSocketDisconnect from fastapi.responses import FileResponse
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from common import ( from asr.config import (
DEFAULT_BACKEND, DEFAULT_BACKEND,
DEFAULT_LANGUAGE, DEFAULT_LANGUAGE,
DEFAULT_MODEL, DEFAULT_MODEL,
@@ -26,16 +23,8 @@ from common import (
ensure_runtime_dirs, ensure_runtime_dirs,
) )
app = FastAPI(title="ASR Gateway", docs_url="/docs", redoc_url="/redoc") router = APIRouter()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
UI_DIR = Path("/app/ui")
BACKENDS = { BACKENDS = {
"faster-whisper": FASTER_WHISPER_URL, "faster-whisper": FASTER_WHISPER_URL,
"qwen3": QWEN3_URL, "qwen3": QWEN3_URL,
@@ -45,22 +34,9 @@ REALTIME_SAMPLE_RATE = 16000
REALTIME_CHUNK_SECONDS = 3 REALTIME_CHUNK_SECONDS = 3
@app.on_event("startup") # ─── Config ────────────────────────────────────────────────────────────────────
def startup() -> None:
ensure_runtime_dirs()
if UI_DIR.exists():
app.mount("/ui", StaticFiles(directory=str(UI_DIR), html=True), name="ui")
app.mount("/assets", StaticFiles(directory=str(UI_DIR)), name="assets")
@router.get("/config")
# ─── Health / Info ────────────────────────────────────────────────────────────
@app.get("/health")
def health() -> Dict[str, Any]:
return {"status": "ok", "ui_installed": UI_DIR.exists(), "api": "/docs"}
@app.get("/config")
def config() -> Dict[str, Any]: def config() -> Dict[str, Any]:
return { return {
"default_backend": DEFAULT_BACKEND, "default_backend": DEFAULT_BACKEND,
@@ -77,17 +53,9 @@ def config() -> Dict[str, Any]:
} }
@app.get("/", response_class=HTMLResponse)
def root() -> Any:
index = UI_DIR / "index.html"
if index.exists():
return FileResponse(index)
return JSONResponse({"status": "ok", "message": "UI not installed", "api": "/docs"})
# ─── History ────────────────────────────────────────────────────────────────── # ─── History ──────────────────────────────────────────────────────────────────
@app.get("/history") @router.get("/history")
def list_history() -> List[Dict[str, Any]]: def list_history() -> List[Dict[str, Any]]:
ensure_runtime_dirs() ensure_runtime_dirs()
records = [] records = []
@@ -112,7 +80,7 @@ def list_history() -> List[Dict[str, Any]]:
return records return records
@app.get("/history/{record_id}") @router.get("/history/{record_id}")
def get_history(record_id: str) -> Dict[str, Any]: def get_history(record_id: str) -> Dict[str, Any]:
safe_id = Path(record_id).name safe_id = Path(record_id).name
p = RESULT_DIR / f"{safe_id}.json" p = RESULT_DIR / f"{safe_id}.json"
@@ -121,7 +89,7 @@ def get_history(record_id: str) -> Dict[str, Any]:
return json.loads(p.read_text(encoding="utf-8")) return json.loads(p.read_text(encoding="utf-8"))
@app.delete("/history/{record_id}") @router.delete("/history/{record_id}")
def delete_history(record_id: str) -> Dict[str, str]: def delete_history(record_id: str) -> Dict[str, str]:
safe_id = Path(record_id).name safe_id = Path(record_id).name
p = RESULT_DIR / f"{safe_id}.json" p = RESULT_DIR / f"{safe_id}.json"
@@ -143,7 +111,7 @@ def delete_history(record_id: str) -> Dict[str, str]:
return {"status": "deleted", "id": safe_id} return {"status": "deleted", "id": safe_id}
@app.get("/uploads/{filename}") @router.get("/uploads/{filename}")
def serve_upload(filename: str) -> FileResponse: def serve_upload(filename: str) -> FileResponse:
safe_name = Path(filename).name safe_name = Path(filename).name
p = UPLOAD_DIR / safe_name p = UPLOAD_DIR / safe_name
@@ -154,7 +122,7 @@ def serve_upload(filename: str) -> FileResponse:
# ─── File Transcription ─────────────────────────────────────────────────────── # ─── File Transcription ───────────────────────────────────────────────────────
@app.post("/transcribe") @router.post("/transcribe")
async def transcribe( async def transcribe(
file: UploadFile = File(...), file: UploadFile = File(...),
backend: str = Form(DEFAULT_BACKEND), backend: str = Form(DEFAULT_BACKEND),
@@ -299,7 +267,7 @@ async def _send_chunk(wav_bytes: bytes, *, model: str, language: str, beam_size:
return resp.json() return resp.json()
@app.websocket("/ws/realtime") @router.websocket("/ws/realtime")
async def realtime_ws(websocket: WebSocket) -> None: async def realtime_ws(websocket: WebSocket) -> None:
await websocket.accept() await websocket.accept()
@@ -384,12 +352,3 @@ async def realtime_ws(websocket: WebSocket) -> None:
await websocket.send_json({"type": "error", "detail": str(e)}) await websocket.send_json({"type": "error", "detail": str(e)})
except Exception: except Exception:
pass pass
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--host", default="0.0.0.0")
parser.add_argument("--port", type=int, default=8000)
args = parser.parse_args()
uvicorn.run(app, host=args.host, port=args.port)

View File

@@ -11,7 +11,7 @@ from faster_whisper import WhisperModel
import sys import sys
sys.path.insert(0, "/app") sys.path.insert(0, "/app")
from common import ( from asr.config import (
COMPUTE_TYPE, COMPUTE_TYPE,
CUSTOM_MODEL_DIR, CUSTOM_MODEL_DIR,
DEFAULT_MODEL, DEFAULT_MODEL,

View File

@@ -12,7 +12,7 @@ from fastapi.responses import JSONResponse
import sys import sys
sys.path.insert(0, "/app") sys.path.insert(0, "/app")
from common import DEVICE, MODEL_CACHE, ensure_runtime_dirs from asr.config import DEVICE, MODEL_CACHE, ensure_runtime_dirs
app = FastAPI(title="ASR Qwen3 Worker") app = FastAPI(title="ASR Qwen3 Worker")

0
app/core/__init__.py Normal file
View File

28
app/core/config.py Normal file
View File

@@ -0,0 +1,28 @@
from __future__ import annotations
import os
def env_str(name: str, default: str = "") -> str:
return os.getenv(name, default)
def env_int(name: str, default: int) -> int:
raw = os.getenv(name)
if raw is None or raw == "":
return default
return int(raw)
def env_float(name: str, default: float) -> float:
raw = os.getenv(name)
if raw is None or raw == "":
return default
return float(raw)
def env_bool(name: str, default: bool = False) -> bool:
raw = os.getenv(name)
if raw is None:
return default
return raw.strip().lower() in {"1", "true", "yes", "on"}

48
app/main.py Normal file
View File

@@ -0,0 +1,48 @@
from __future__ import annotations
from pathlib import Path
from typing import Any, Dict
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from asr.config import ensure_runtime_dirs
from asr.router import router as asr_router
from tts.router import router as tts_router
app = FastAPI(title="ASR/TTS Gateway", docs_url="/docs", redoc_url="/redoc")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
UI_DIR = Path("/app/ui")
app.include_router(asr_router, prefix="/asr", tags=["asr"])
app.include_router(tts_router, prefix="/tts", tags=["tts"])
@app.on_event("startup")
def startup() -> None:
ensure_runtime_dirs()
if UI_DIR.exists():
app.mount("/ui", StaticFiles(directory=str(UI_DIR), html=True), name="ui")
app.mount("/assets", StaticFiles(directory=str(UI_DIR / "assets")), name="assets")
@app.get("/health")
def health() -> Dict[str, Any]:
return {"status": "ok", "ui_installed": UI_DIR.exists(), "api": "/docs"}
@app.get("/", response_class=HTMLResponse)
def root() -> Any:
index = UI_DIR / "index.html"
if index.exists():
return FileResponse(index)
return JSONResponse({"status": "ok", "message": "UI not installed", "api": "/docs"})

View File

@@ -4,7 +4,7 @@ logfile=/dev/null
pidfile=/tmp/supervisord.pid pidfile=/tmp/supervisord.pid
[program:faster_whisper] [program:faster_whisper]
command=/opt/venvs/faster_whisper/bin/python /app/workers/faster_whisper_worker.py --host 0.0.0.0 --port 8001 command=/opt/venvs/faster_whisper/bin/python /app/asr/workers/faster_whisper_worker.py --host 0.0.0.0 --port 8001
directory=/app directory=/app
autostart=true autostart=true
autorestart=true autorestart=true
@@ -15,7 +15,7 @@ stderr_logfile_maxbytes=0
priority=10 priority=10
[program:qwen3] [program:qwen3]
command=/opt/venvs/qwen3/bin/python /app/workers/qwen3_worker.py --host 0.0.0.0 --port 8004 command=/opt/venvs/qwen3/bin/python /app/asr/workers/qwen3_worker.py --host 0.0.0.0 --port 8004
directory=/app directory=/app
autostart=true autostart=true
autorestart=true autorestart=true
@@ -26,7 +26,7 @@ stderr_logfile_maxbytes=0
priority=20 priority=20
[program:gateway] [program:gateway]
command=/opt/venvs/gateway/bin/python -m uvicorn gateway:app --host 0.0.0.0 --port 8000 command=/opt/venvs/gateway/bin/python -m uvicorn main:app --host 0.0.0.0 --port 8000
directory=/app directory=/app
autostart=true autostart=true
autorestart=true autorestart=true

0
app/tts/__init__.py Normal file
View File

12
app/tts/config.py Normal file
View File

@@ -0,0 +1,12 @@
from __future__ import annotations
from pathlib import Path
from core.config import env_str
TTS_BASE_DIR = Path(env_str("TTS_BASE_DIR", "/srv/tts"))
TTS_MODEL_CACHE = Path(env_str("TTS_MODEL_CACHE_DIR", str(TTS_BASE_DIR / "models-cache")))
TTS_UPLOAD_DIR = TTS_BASE_DIR / "uploads"
TTS_RESULT_DIR = TTS_BASE_DIR / "results"
DEFAULT_TTS_BACKEND = env_str("DEFAULT_TTS_BACKEND", "")

29
app/tts/router.py Normal file
View File

@@ -0,0 +1,29 @@
from __future__ import annotations
from typing import Any, Dict, List
from fastapi import APIRouter, HTTPException
from tts.config import DEFAULT_TTS_BACKEND
router = APIRouter()
@router.get("/config")
def config() -> Dict[str, Any]:
return {
"status": "not_implemented",
"default_backend": DEFAULT_TTS_BACKEND,
"backends": {},
"message": "TTS 백엔드가 아직 연동되지 않았습니다.",
}
@router.get("/history")
def list_history() -> List[Dict[str, Any]]:
return []
@router.post("/synthesize")
def synthesize() -> None:
raise HTTPException(status_code=501, detail="TTS 합성 기능은 아직 구현되지 않았습니다.")

View File

@@ -36,6 +36,16 @@ body {
.tab-btn:hover { color: #e7ebf3; } .tab-btn:hover { color: #e7ebf3; }
.tab-btn.active { color: #e7ebf3; border-bottom-color: #3d63ff; } .tab-btn.active { color: #e7ebf3; border-bottom-color: #3d63ff; }
/* Top-level tab group (ASR / TTS) gets a bolder, larger look */
.tab-group--top { margin-bottom: 8px; }
.tab-group--top > .tabs { margin-bottom: 24px; border-bottom: 2px solid #273056; }
.tab-group--top > .tabs > .tab-btn {
font-size: 1.15em;
font-weight: 700;
padding: 14px 28px;
border-bottom-width: 4px;
}
/* Card */ /* Card */
.card { .card {
background: #121933; background: #121933;

View File

@@ -1,10 +1,10 @@
// ─── Config (fetched from /config) ─────────────────────────────────────────── // ─── Config (fetched from /asr/config) ────────────────────────────────────────
let SERVER_CONFIG = null; let SERVER_CONFIG = null;
async function loadConfig() { async function loadConfig() {
try { try {
const r = await fetch('/config'); const r = await fetch('/asr/config');
SERVER_CONFIG = await r.json(); SERVER_CONFIG = await r.json();
} catch (e) { } catch (e) {
SERVER_CONFIG = { SERVER_CONFIG = {
@@ -17,117 +17,7 @@ async function loadConfig() {
}, },
}; };
} }
initUI(); initAsrUI();
}
// ─── 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 ──────────────────────────────────────────────────────────────── // ─── FILE TAB ────────────────────────────────────────────────────────────────
@@ -201,7 +91,7 @@ document.getElementById('file-form').addEventListener('submit', async (e) => {
document.getElementById('file-submit-btn').disabled = true; document.getElementById('file-submit-btn').disabled = true;
try { try {
const resp = await fetch('/transcribe', {method:'POST', body: fd}); const resp = await fetch('/asr/transcribe', {method:'POST', body: fd});
const payload = await resp.json(); const payload = await resp.json();
if (!resp.ok) throw new Error(payload.detail || JSON.stringify(payload)); if (!resp.ok) throw new Error(payload.detail || JSON.stringify(payload));
fileLastPayload = payload; fileLastPayload = payload;
@@ -278,7 +168,7 @@ async function startRecording() {
}; };
const proto = location.protocol === 'https:' ? 'wss' : 'ws'; const proto = location.protocol === 'https:' ? 'wss' : 'ws';
ws = new WebSocket(`${proto}://${location.host}/ws/realtime`); ws = new WebSocket(`${proto}://${location.host}/asr/ws/realtime`);
ws.binaryType = 'arraybuffer'; ws.binaryType = 'arraybuffer';
ws.onopen = () => ws.send(JSON.stringify(cfg)); ws.onopen = () => ws.send(JSON.stringify(cfg));
@@ -292,7 +182,7 @@ async function startRecording() {
ws.close(); return; ws.close(); return;
} }
audioCtx = new AudioContext({sampleRate: 16000}); audioCtx = new AudioContext({sampleRate: 16000});
await audioCtx.audioWorklet.addModule('/assets/pcm-processor.js'); await audioCtx.audioWorklet.addModule('/assets/js/pcm-processor.js');
sourceNode = audioCtx.createMediaStreamSource(mediaStream); sourceNode = audioCtx.createMediaStreamSource(mediaStream);
workletNode = new AudioWorkletNode(audioCtx, 'pcm-processor'); workletNode = new AudioWorkletNode(audioCtx, 'pcm-processor');
workletNode.port.onmessage = (e) => { workletNode.port.onmessage = (e) => {
@@ -367,7 +257,7 @@ async function loadHistory() {
historyDetail.style.display = 'none'; historyDetail.style.display = 'none';
historyList.style.display = ''; historyList.style.display = '';
try { try {
const r = await fetch('/history'); const r = await fetch('/asr/history');
const records = await r.json(); const records = await r.json();
renderHistoryList(records); renderHistoryList(records);
} catch (e) { } catch (e) {
@@ -406,7 +296,7 @@ function renderHistoryList(records) {
async function openHistoryDetail(id) { async function openHistoryDetail(id) {
try { try {
const r = await fetch(`/history/${id}`); const r = await fetch(`/asr/history/${id}`);
if (!r.ok) throw new Error(await r.text()); if (!r.ok) throw new Error(await r.text());
currentRecord = await r.json(); currentRecord = await r.json();
currentRecord._id = id; currentRecord._id = id;
@@ -442,7 +332,7 @@ function renderDetail(data) {
const uploadPath = data.upload_file || ''; const uploadPath = data.upload_file || '';
const uploadName = uploadPath ? uploadPath.split('/').pop() : ''; const uploadName = uploadPath ? uploadPath.split('/').pop() : '';
if (uploadName) { if (uploadName) {
const url = `/uploads/${encodeURIComponent(uploadName)}`; const url = `/asr/uploads/${encodeURIComponent(uploadName)}`;
const ext = uploadName.split('.').pop().toLowerCase(); const ext = uploadName.split('.').pop().toLowerCase();
const videoExts = ['mp4','mkv','webm','avi','mov','m4v']; const videoExts = ['mp4','mkv','webm','avi','mov','m4v'];
if (videoExts.includes(ext)) { if (videoExts.includes(ext)) {
@@ -478,7 +368,7 @@ document.getElementById('detail-delete-btn').addEventListener('click', async ()
if (!currentRecord || !currentRecord._id) return; if (!currentRecord || !currentRecord._id) return;
if (!confirm('이 내역을 삭제하시겠습니까? 원본 파일도 함께 삭제됩니다.')) return; if (!confirm('이 내역을 삭제하시겠습니까? 원본 파일도 함께 삭제됩니다.')) return;
try { try {
const r = await fetch(`/history/${currentRecord._id}`, {method: 'DELETE'}); const r = await fetch(`/asr/history/${currentRecord._id}`, {method: 'DELETE'});
if (!r.ok) throw new Error(await r.text()); if (!r.ok) throw new Error(await r.text());
historyDetail.style.display = 'none'; historyDetail.style.display = 'none';
historyList.style.display = ''; historyList.style.display = '';
@@ -503,9 +393,12 @@ document.getElementById('detail-dl-json-btn').addEventListener('click', () => {
downloadBlob(fname, JSON.stringify(currentRecord||{}, null, 2), 'application/json;charset=utf-8'); 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 ───────────────────────────────────────────────────────────────────── // ─── Init ─────────────────────────────────────────────────────────────────────
function initUI() { function initAsrUI() {
populateModels(backendSel.value); populateModels(backendSel.value);
makeToggle('antirep-toggle', 'antirep-panel', 'antirep-chevron'); makeToggle('antirep-toggle', 'antirep-panel', 'antirep-chevron');
makeToggle('rt-antirep-toggle', 'rt-antirep-panel', 'rt-antirep-chevron'); makeToggle('rt-antirep-toggle', 'rt-antirep-panel', 'rt-antirep-chevron');

121
app/ui/assets/js/common.js Normal file
View File

@@ -0,0 +1,121 @@
// ─── Shared utilities (used by both ASR and TTS tabs) ─────────────────────────
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);
}
// ─── 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)';
});
}
// ─── Nestable tab groups (top-level ASR/TTS switch + each domain's sub-tabs) ───
//
// Each `.tab-group` owns one `.tabs` row of `.tab-btn` and a set of sibling
// `.tab-panel` elements. Scoping with `:scope >` means groups can nest freely
// (e.g. the top ASR/TTS switch contains a `.tab-group` per domain) without
// their click handlers interfering with each other.
function initTabGroups() {
document.querySelectorAll('.tab-group').forEach(group => {
const buttons = group.querySelectorAll(':scope > .tabs > .tab-btn');
const panels = group.querySelectorAll(':scope > .tab-panel');
buttons.forEach(btn => {
btn.addEventListener('click', () => {
buttons.forEach(b => b.classList.remove('active'));
panels.forEach(p => p.style.display = 'none');
btn.classList.add('active');
const target = document.getElementById(btn.dataset.target);
if (target) target.style.display = '';
});
});
});
}
initTabGroups();

15
app/ui/assets/js/tts.js Normal file
View File

@@ -0,0 +1,15 @@
// TTS tab is a structural placeholder until a synthesis backend is wired in.
// This just confirms the /tts router is reachable; the UI itself is a static
// "coming soon" card (see index.html).
async function loadTtsConfig() {
try {
const r = await fetch('/tts/config');
const cfg = await r.json();
console.log('[tts] config', cfg);
} catch (e) {
console.warn('[tts] config fetch failed', e);
}
}
loadTtsConfig();

View File

@@ -3,298 +3,337 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>ASR 전사 서비스</title> <title>ASR · TTS 콘솔</title>
<link rel="stylesheet" href="/assets/styles.css"> <link rel="stylesheet" href="/assets/css/styles.css">
</head> </head>
<body> <body>
<main class="wrap"> <main class="wrap">
<header class="page-header"> <header class="page-header">
<h1>ASR 전사 서비스</h1> <h1>ASR · TTS 콘솔</h1>
<p class="muted">파일 전사 · 실시간 전사 · 처리 내역</p> <p class="muted">음성 인식(ASR)과 음성 합성(TTS)을 한 곳에서</p>
</header> </header>
<div class="tabs"> <div class="tab-group tab-group--top">
<button class="tab-btn active" data-tab="file">파일 전사</button> <div class="tabs">
<button class="tab-btn" data-tab="realtime">실시간 전사</button> <button class="tab-btn active" data-target="top-asr">ASR · 음성 인식</button>
<button class="tab-btn" data-tab="history">처리 내역</button> <button class="tab-btn" data-target="top-tts">TTS · 음성 합성</button>
</div> </div>
<!-- ═══════════════════ TAB: FILE ═══════════════════ --> <!-- ═══════════════════ TOP TAB: ASR ═══════════════════ -->
<div id="tab-file" class="tab-panel"> <div id="top-asr" class="tab-panel">
<section class="card"> <div class="tab-group">
<form id="file-form"> <div class="tabs">
<button class="tab-btn active" data-target="tab-file">파일 전사</button>
<label>오디오 / 동영상 파일 <button class="tab-btn" data-target="tab-realtime">실시간 전사</button>
<input type="file" id="file-input" name="file" accept="audio/*,video/*" required> <button class="tab-btn" data-target="tab-history">처리 내역</button>
</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> </div>
<label>커스텀 모델 경로 (선택, faster-whisper 전용) <!-- TAB: FILE -->
<input type="text" id="custom_model_path" name="custom_model_path" placeholder="/srv/asr/custom-models/my-model"> <div id="tab-file" class="tab-panel">
</label> <section class="card">
<form id="file-form">
<div class="grid2"> <label>오디오 / 동영상 파일
<label>언어 <input type="file" id="file-input" name="file" accept="audio/*,video/*" required>
<input type="text" id="language" name="language" value="ko" placeholder="ko"> </label>
</label>
<label>작업 <div class="grid2">
<select id="task" name="task"> <label>백엔드
<option value="transcribe" selected>transcribe</option> <select id="backend" name="backend">
<option value="translate">translate (영어로 번역)</option> <option value="faster-whisper" selected>faster-whisper</option>
</select> <option value="qwen3">Qwen3-ASR</option>
</label> </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" 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> </div>
<!-- faster-whisper only --> <!-- TAB: REALTIME -->
<div id="fw-options"> <div id="tab-realtime" class="tab-panel" style="display:none">
<div class="grid2"> <section class="card">
<label>Beam size <h2>실시간 전사 설정</h2>
<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"> <div class="grid2">
<label>no_repeat_ngram_size <label>모델
<input type="number" id="no_repeat_ngram_size" name="no_repeat_ngram_size" value="0" min="0" max="10"> <select id="rt-model">
<span class="hint">0=비활성, 3~5 권장</span> <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>
<label>repetition_penalty <label>언어
<input type="number" id="repetition_penalty" name="repetition_penalty" value="1.0" min="1.0" max="2.0" step="0.05"> <input type="text" id="rt-language" value="ko" placeholder="ko">
<span class="hint">1.0=없음, 1.1~1.3 권장</span>
</label> </label>
<label>compression_ratio_threshold </div>
<input type="number" id="compression_ratio_threshold" name="compression_ratio_threshold" value="2.4" min="0.5" max="5.0" step="0.1"> <div class="grid2">
<label>Beam size
<input type="number" id="rt-beam_size" value="3" min="1" max="10">
</label> </label>
<label>log_prob_threshold <label>청크 처리 간격
<input type="number" id="log_prob_threshold" name="log_prob_threshold" value="-1.0" min="-5.0" max="0.0" step="0.1"> <select id="rt-chunk_seconds">
<option value="2">2초 (빠른 반응)</option>
<option value="3" selected>3초 (균형)</option>
<option value="5">5초 (더 정확)</option>
</select>
</label> </label>
<label>no_speech_threshold </div>
<input type="number" id="no_speech_threshold" name="no_speech_threshold" value="0.6" min="0.0" max="1.0" step="0.05">
</label> <div class="section-toggle">
<label class="inline" style="align-self:end; padding-top:8px"> <button type="button" class="toggle-btn" id="rt-antirep-toggle">
<input type="checkbox" id="condition_on_previous_text" name="condition_on_previous_text" checked> 반복 억제 옵션 <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> </label>
</div> </div>
</div> </section>
<div class="divider"></div> <section class="card">
<label class="inline"> <div class="mic-row">
<input type="checkbox" id="diarize" name="diarize"> <button id="rt-start-btn" class="mic-btn">&#9679; 녹음 시작</button>
화자 분리 (Speaker Diarization) <button id="rt-stop-btn" class="mic-btn stop-btn" disabled>&#9632; 녹음 중지</button>
</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 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>
</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>
<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>
<!-- 상세 뷰 --> <!-- ═══════════════════ TOP TAB: TTS ═══════════════════ -->
<div id="history-detail" style="display:none"> <div id="top-tts" class="tab-panel" style="display:none">
<section class="card"> <div class="tab-group">
<div class="detail-header"> <div class="tabs">
<button type="button" id="history-back-btn" class="btn-secondary">← 목록으로</button> <button class="tab-btn active" data-target="tab-tts-synth">텍스트 → 음성</button>
<span id="detail-title" class="detail-title"></span> <button class="tab-btn" data-target="tab-tts-history">처리 내역</button>
<button type="button" id="detail-delete-btn" class="btn-danger">삭제</button>
</div> </div>
</section>
<section class="card" id="detail-media-section" style="display:none"> <div id="tab-tts-synth" class="tab-panel">
<h2>원본 파일</h2> <section class="card">
<div id="detail-media-container"></div> <h2>TTS 기능 준비 중</h2>
</section> <p class="muted">음성 합성 백엔드는 아직 연동되지 않았습니다. API 구조(<code>/tts/*</code>)는
준비되어 있으며, 엔진이 연결되면 이 화면에서 텍스트를 음성으로 변환할 수 있습니다.</p>
<section class="card"> </section>
<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> </div>
</section>
<section class="card"> <div id="tab-tts-history" class="tab-panel" style="display:none">
<h2>JSON 결과</h2> <section class="card">
<pre id="detail-json" class="result-json">{}</pre> <h2>처리 내역</h2>
</section> <div class="muted" style="padding:16px">아직 처리된 내역이 없습니다.</div>
</section>
</div>
</div>
</div> </div>
</div> </div>
</main> </main>
<script src="/assets/app.js"></script> <script src="/assets/js/common.js"></script>
<script src="/assets/js/asr.js"></script>
<script src="/assets/js/tts.js"></script>
</body> </body>
</html> </html>