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
import os
from pathlib import Path
from typing import Optional
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"}
from core.config import env_str
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")))

View File

@@ -9,13 +9,10 @@ from pathlib import Path
from typing import Any, Dict, List, Optional
import httpx
import uvicorn
from fastapi import FastAPI, File, Form, HTTPException, UploadFile, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi import APIRouter, File, Form, HTTPException, UploadFile, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse
from common import (
from asr.config import (
DEFAULT_BACKEND,
DEFAULT_LANGUAGE,
DEFAULT_MODEL,
@@ -26,16 +23,8 @@ from common import (
ensure_runtime_dirs,
)
app = FastAPI(title="ASR Gateway", docs_url="/docs", redoc_url="/redoc")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
router = APIRouter()
UI_DIR = Path("/app/ui")
BACKENDS = {
"faster-whisper": FASTER_WHISPER_URL,
"qwen3": QWEN3_URL,
@@ -45,22 +34,9 @@ REALTIME_SAMPLE_RATE = 16000
REALTIME_CHUNK_SECONDS = 3
@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)), name="assets")
# ─── Config ────────────────────────────────────────────────────────────────────
# ─── Health / Info ────────────────────────────────────────────────────────────
@app.get("/health")
def health() -> Dict[str, Any]:
return {"status": "ok", "ui_installed": UI_DIR.exists(), "api": "/docs"}
@app.get("/config")
@router.get("/config")
def config() -> Dict[str, Any]:
return {
"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 ──────────────────────────────────────────────────────────────────
@app.get("/history")
@router.get("/history")
def list_history() -> List[Dict[str, Any]]:
ensure_runtime_dirs()
records = []
@@ -112,7 +80,7 @@ def list_history() -> List[Dict[str, Any]]:
return records
@app.get("/history/{record_id}")
@router.get("/history/{record_id}")
def get_history(record_id: str) -> Dict[str, Any]:
safe_id = Path(record_id).name
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"))
@app.delete("/history/{record_id}")
@router.delete("/history/{record_id}")
def delete_history(record_id: str) -> Dict[str, str]:
safe_id = Path(record_id).name
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}
@app.get("/uploads/{filename}")
@router.get("/uploads/{filename}")
def serve_upload(filename: str) -> FileResponse:
safe_name = Path(filename).name
p = UPLOAD_DIR / safe_name
@@ -154,7 +122,7 @@ def serve_upload(filename: str) -> FileResponse:
# ─── File Transcription ───────────────────────────────────────────────────────
@app.post("/transcribe")
@router.post("/transcribe")
async def transcribe(
file: UploadFile = File(...),
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()
@app.websocket("/ws/realtime")
@router.websocket("/ws/realtime")
async def realtime_ws(websocket: WebSocket) -> None:
await websocket.accept()
@@ -384,12 +352,3 @@ async def realtime_ws(websocket: WebSocket) -> None:
await websocket.send_json({"type": "error", "detail": str(e)})
except Exception:
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
sys.path.insert(0, "/app")
from common import (
from asr.config import (
COMPUTE_TYPE,
CUSTOM_MODEL_DIR,
DEFAULT_MODEL,

View File

@@ -12,7 +12,7 @@ from fastapi.responses import JSONResponse
import sys
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")

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
[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
autostart=true
autorestart=true
@@ -15,7 +15,7 @@ stderr_logfile_maxbytes=0
priority=10
[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
autostart=true
autorestart=true
@@ -26,7 +26,7 @@ stderr_logfile_maxbytes=0
priority=20
[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
autostart=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.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 {
background: #121933;

View File

@@ -1,10 +1,10 @@
// ─── Config (fetched from /config) ───────────────────────────────────────────
// ─── Config (fetched from /asr/config) ────────────────────────────────────────
let SERVER_CONFIG = null;
async function loadConfig() {
try {
const r = await fetch('/config');
const r = await fetch('/asr/config');
SERVER_CONFIG = await r.json();
} catch (e) {
SERVER_CONFIG = {
@@ -17,117 +17,7 @@ async function loadConfig() {
},
};
}
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)';
});
initAsrUI();
}
// ─── FILE TAB ────────────────────────────────────────────────────────────────
@@ -201,7 +91,7 @@ document.getElementById('file-form').addEventListener('submit', async (e) => {
document.getElementById('file-submit-btn').disabled = true;
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();
if (!resp.ok) throw new Error(payload.detail || JSON.stringify(payload));
fileLastPayload = payload;
@@ -278,7 +168,7 @@ async function startRecording() {
};
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.onopen = () => ws.send(JSON.stringify(cfg));
@@ -292,7 +182,7 @@ async function startRecording() {
ws.close(); return;
}
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);
workletNode = new AudioWorkletNode(audioCtx, 'pcm-processor');
workletNode.port.onmessage = (e) => {
@@ -367,7 +257,7 @@ async function loadHistory() {
historyDetail.style.display = 'none';
historyList.style.display = '';
try {
const r = await fetch('/history');
const r = await fetch('/asr/history');
const records = await r.json();
renderHistoryList(records);
} catch (e) {
@@ -406,7 +296,7 @@ function renderHistoryList(records) {
async function openHistoryDetail(id) {
try {
const r = await fetch(`/history/${id}`);
const r = await fetch(`/asr/history/${id}`);
if (!r.ok) throw new Error(await r.text());
currentRecord = await r.json();
currentRecord._id = id;
@@ -442,7 +332,7 @@ function renderDetail(data) {
const uploadPath = data.upload_file || '';
const uploadName = uploadPath ? uploadPath.split('/').pop() : '';
if (uploadName) {
const url = `/uploads/${encodeURIComponent(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)) {
@@ -478,7 +368,7 @@ 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'});
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 = '';
@@ -503,9 +393,12 @@ document.getElementById('detail-dl-json-btn').addEventListener('click', () => {
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 initUI() {
function initAsrUI() {
populateModels(backendSel.value);
makeToggle('antirep-toggle', 'antirep-panel', '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,23 +3,32 @@
<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">
<title>ASR · TTS 콘솔</title>
<link rel="stylesheet" href="/assets/css/styles.css">
</head>
<body>
<main class="wrap">
<header class="page-header">
<h1>ASR 전사 서비스</h1>
<p class="muted">파일 전사 · 실시간 전사 · 처리 내역</p>
<h1>ASR · TTS 콘솔</h1>
<p class="muted">음성 인식(ASR)과 음성 합성(TTS)을 한 곳에서</p>
</header>
<div class="tab-group tab-group--top">
<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>
<button class="tab-btn active" data-target="top-asr">ASR · 음성 인식</button>
<button class="tab-btn" data-target="top-tts">TTS · 음성 합성</button>
</div>
<!-- ═══════════════════ TAB: FILE ═══════════════════ -->
<!-- ═══════════════════ TOP TAB: ASR ═══════════════════ -->
<div id="top-asr" class="tab-panel">
<div class="tab-group">
<div class="tabs">
<button class="tab-btn active" data-target="tab-file">파일 전사</button>
<button class="tab-btn" data-target="tab-realtime">실시간 전사</button>
<button class="tab-btn" data-target="tab-history">처리 내역</button>
</div>
<!-- TAB: FILE -->
<div id="tab-file" class="tab-panel">
<section class="card">
<form id="file-form">
@@ -99,7 +108,7 @@
<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>
<input type="checkbox" id="condition_on_previous_text" checked>
이전 텍스트 조건화
</label>
</div>
@@ -155,7 +164,7 @@
</section>
</div>
<!-- ═══════════════════ TAB: REALTIME ═══════════════════ -->
<!-- TAB: REALTIME -->
<div id="tab-realtime" class="tab-panel" style="display:none">
<section class="card">
<h2>실시간 전사 설정</h2>
@@ -239,7 +248,7 @@
</section>
</div>
<!-- ═══════════════════ TAB: HISTORY ═══════════════════ -->
<!-- TAB: HISTORY -->
<div id="tab-history" class="tab-panel" style="display:none">
<section class="card">
<div class="history-toolbar">
@@ -293,8 +302,38 @@
</section>
</div>
</div>
</div>
</div>
<!-- ═══════════════════ TOP TAB: TTS ═══════════════════ -->
<div id="top-tts" class="tab-panel" style="display:none">
<div class="tab-group">
<div class="tabs">
<button class="tab-btn active" data-target="tab-tts-synth">텍스트 → 음성</button>
<button class="tab-btn" data-target="tab-tts-history">처리 내역</button>
</div>
<div id="tab-tts-synth" class="tab-panel">
<section class="card">
<h2>TTS 기능 준비 중</h2>
<p class="muted">음성 합성 백엔드는 아직 연동되지 않았습니다. API 구조(<code>/tts/*</code>)는
준비되어 있으며, 엔진이 연결되면 이 화면에서 텍스트를 음성으로 변환할 수 있습니다.</p>
</section>
</div>
<div id="tab-tts-history" class="tab-panel" style="display:none">
<section class="card">
<h2>처리 내역</h2>
<div class="muted" style="padding:16px">아직 처리된 내역이 없습니다.</div>
</section>
</div>
</div>
</div>
</div>
</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>
</html>