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:
0
app/asr/__init__.py
Normal file
0
app/asr/__init__.py
Normal 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")))
|
||||||
@@ -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)
|
|
||||||
@@ -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,
|
||||||
@@ -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
0
app/core/__init__.py
Normal file
28
app/core/config.py
Normal file
28
app/core/config.py
Normal 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
48
app/main.py
Normal 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"})
|
||||||
@@ -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
0
app/tts/__init__.py
Normal file
12
app/tts/config.py
Normal file
12
app/tts/config.py
Normal 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
29
app/tts/router.py
Normal 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 합성 기능은 아직 구현되지 않았습니다.")
|
||||||
@@ -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;
|
||||||
@@ -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
121
app/ui/assets/js/common.js
Normal 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
15
app/ui/assets/js/tts.js
Normal 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();
|
||||||
@@ -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">● 녹음 시작</button>
|
||||||
화자 분리 (Speaker Diarization)
|
<button id="rt-stop-btn" class="mic-btn stop-btn" disabled>■ 녹음 중지</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">● 녹음 시작</button>
|
|
||||||
<button id="rt-stop-btn" class="mic-btn stop-btn" disabled>■ 녹음 중지</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>
|
||||||
|
|||||||
Reference in New Issue
Block a user