diff --git a/app/asr/__init__.py b/app/asr/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/common.py b/app/asr/config.py similarity index 69% rename from app/common.py rename to app/asr/config.py index 28d43ea..7c66d06 100644 --- a/app/common.py +++ b/app/asr/config.py @@ -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"))) diff --git a/app/gateway.py b/app/asr/router.py similarity index 87% rename from app/gateway.py rename to app/asr/router.py index 5c9c912..d353ea4 100644 --- a/app/gateway.py +++ b/app/asr/router.py @@ -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) diff --git a/app/workers/faster_whisper_worker.py b/app/asr/workers/faster_whisper_worker.py similarity index 99% rename from app/workers/faster_whisper_worker.py rename to app/asr/workers/faster_whisper_worker.py index 3f3f54d..7bd2ba3 100644 --- a/app/workers/faster_whisper_worker.py +++ b/app/asr/workers/faster_whisper_worker.py @@ -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, diff --git a/app/workers/qwen3_worker.py b/app/asr/workers/qwen3_worker.py similarity index 98% rename from app/workers/qwen3_worker.py rename to app/asr/workers/qwen3_worker.py index 3373b65..51b6864 100644 --- a/app/workers/qwen3_worker.py +++ b/app/asr/workers/qwen3_worker.py @@ -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") diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..39273eb --- /dev/null +++ b/app/core/config.py @@ -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"} diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..6fa6505 --- /dev/null +++ b/app/main.py @@ -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"}) diff --git a/app/supervisord.conf b/app/supervisord.conf index 0c1f905..83d1849 100644 --- a/app/supervisord.conf +++ b/app/supervisord.conf @@ -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 diff --git a/app/tts/__init__.py b/app/tts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/tts/config.py b/app/tts/config.py new file mode 100644 index 0000000..9f86238 --- /dev/null +++ b/app/tts/config.py @@ -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", "") diff --git a/app/tts/router.py b/app/tts/router.py new file mode 100644 index 0000000..a7cbd30 --- /dev/null +++ b/app/tts/router.py @@ -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 합성 기능은 아직 구현되지 않았습니다.") diff --git a/app/ui/styles.css b/app/ui/assets/css/styles.css similarity index 95% rename from app/ui/styles.css rename to app/ui/assets/css/styles.css index d5c6dab..e5e2d2e 100644 --- a/app/ui/styles.css +++ b/app/ui/assets/css/styles.css @@ -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; diff --git a/app/ui/app.js b/app/ui/assets/js/asr.js similarity index 78% rename from app/ui/app.js rename to app/ui/assets/js/asr.js index ab67feb..d50c296 100644 --- a/app/ui/app.js +++ b/app/ui/assets/js/asr.js @@ -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 += `
-
- - ${esc(g.speaker)} - ${fmtTime(t0)} – ${fmtTime(t1)} -
-
${esc(text)}
-
`; - } - 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'); diff --git a/app/ui/assets/js/common.js b/app/ui/assets/js/common.js new file mode 100644 index 0000000..3b66c0f --- /dev/null +++ b/app/ui/assets/js/common.js @@ -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 += `
+
+ + ${esc(g.speaker)} + ${fmtTime(t0)} – ${fmtTime(t1)} +
+
${esc(text)}
+
`; + } + 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(); diff --git a/app/ui/pcm-processor.js b/app/ui/assets/js/pcm-processor.js similarity index 100% rename from app/ui/pcm-processor.js rename to app/ui/assets/js/pcm-processor.js diff --git a/app/ui/assets/js/tts.js b/app/ui/assets/js/tts.js new file mode 100644 index 0000000..c0459b6 --- /dev/null +++ b/app/ui/assets/js/tts.js @@ -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(); diff --git a/app/ui/index.html b/app/ui/index.html index 5009022..ec5dddd 100644 --- a/app/ui/index.html +++ b/app/ui/index.html @@ -3,298 +3,337 @@ - ASR 전사 서비스 - + ASR · TTS 콘솔 +
-
- - - -
+
+
+ + +
- -
-
-
- - - -
- - + +
+
+
+ + +
- + +
+
+ -
- - + + +
+ + +
+ + + +
+ + +
+ + +
+
+ + +
+ + +
+ +
+ + +
+ + +
+ +
+ + + + +
+ +
+ +
+

상태

+
대기 중
+
+ + + +
+

텍스트 결과

+ +
+ +
+

JSON 결과

+
{}
+
- -
-
- - -
- - -
- -
-
-
- - - - - - - -
- + + +