From 3cc8fb26946dfe0f5735b34164765fad1d6e0667 Mon Sep 17 00:00:00 2001 From: dsyoon Date: Fri, 30 Jan 2026 13:17:24 +0900 Subject: [PATCH] Add client/server split and TTS app Set up FastAPI server, vanilla UI, and deployment scripts for the TTS app, including DB/ffmpeg wiring and Apache config. --- .env | 5 ++ .env.example | 5 ++ README.md | 46 ++++++++++ apache/tts.conf | 12 +++ client/run.sh | 7 ++ client/static/app.js | 142 ++++++++++++++++++++++++++++++ client/static/styles.css | 114 ++++++++++++++++++++++++ client/templates/index.html | 32 +++++++ requirements.txt | 6 ++ server/__init__.py | 1 + server/db.py | 138 +++++++++++++++++++++++++++++ server/main.py | 170 ++++++++++++++++++++++++++++++++++++ server/run.sh | 17 ++++ server/tts_service.py | 42 +++++++++ 14 files changed, 737 insertions(+) create mode 100644 .env create mode 100644 .env.example create mode 100644 README.md create mode 100644 apache/tts.conf create mode 100644 client/run.sh create mode 100644 client/static/app.js create mode 100644 client/static/styles.css create mode 100644 client/templates/index.html create mode 100644 requirements.txt create mode 100644 server/__init__.py create mode 100644 server/db.py create mode 100644 server/main.py create mode 100644 server/run.sh create mode 100644 server/tts_service.py diff --git a/.env b/.env new file mode 100644 index 0000000..d4aad1d --- /dev/null +++ b/.env @@ -0,0 +1,5 @@ +DB_HOST=ncue.net +DB_PORT=5432 +DB_NAME=tts +DB_USER=ncue +DB_PASSWORD=ncue5004! diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d4aad1d --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +DB_HOST=ncue.net +DB_PORT=5432 +DB_NAME=tts +DB_USER=ncue +DB_PASSWORD=ncue5004! diff --git a/README.md b/README.md new file mode 100644 index 0000000..ddbaebc --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +# TTS 저장/조회/삭제 앱 + +## 프로젝트 구조 +``` +. +├── client +│ ├── static +│ └── templates +├── server +│ ├── db.py +│ ├── main.py +│ └── tts_service.py +├── resources +├── .env +├── .env.example +├── requirements.txt +└── README.md +``` + +## 실행 방법 +1) 의존성 설치 +``` +pip install -r requirements.txt +``` + +2) 환경 변수 설정 +``` +cp .env.example .env +``` +`.env`에 DB 계정 정보를 입력하세요. + +3) 서버 실행 +``` +uvicorn server.main:app --reload +``` + +4) 접속 +``` +http://localhost:8000 +``` + +## 주의 사항 +- PostgreSQL 접속 정보는 프로젝트 루트의 `.env`에서 로드합니다. +- `server/`에서 실행하더라도 루트 `.env`가 적용됩니다. +- ffmpeg가 설치되어 있어야 합니다. +- mp3 파일은 `resources/` 아래에 저장됩니다. diff --git a/apache/tts.conf b/apache/tts.conf new file mode 100644 index 0000000..08a84bd --- /dev/null +++ b/apache/tts.conf @@ -0,0 +1,12 @@ + + ServerName tts.ncue.net + + ProxyPreserveHost On + ProxyRequests Off + + ProxyPass / http://127.0.0.1:8018/ + ProxyPassReverse / http://127.0.0.1:8018/ + + ErrorLog ${APACHE_LOG_DIR}/tts_error.log + CustomLog ${APACHE_LOG_DIR}/tts_access.log combined + diff --git a/client/run.sh b/client/run.sh new file mode 100644 index 0000000..b0cfe2a --- /dev/null +++ b/client/run.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd /home/dsyoon/workspace/tts/client + +echo "Client is served by the FastAPI server." +echo "No build step required." diff --git a/client/static/app.js b/client/static/app.js new file mode 100644 index 0000000..2f3a05c --- /dev/null +++ b/client/static/app.js @@ -0,0 +1,142 @@ +const listEl = document.getElementById("tts-list"); +const textInput = document.getElementById("text-input"); +const saveBtn = document.getElementById("save-btn"); +const editBtn = document.getElementById("edit-btn"); +const deleteBtn = document.getElementById("delete-btn"); +const cancelBtn = document.getElementById("cancel-btn"); +const downloadLink = document.getElementById("download-link"); + +let items = []; +let editMode = false; +const selectedIds = new Set(); + +function setEditMode(isEdit) { + editMode = isEdit; + selectedIds.clear(); + editBtn.classList.toggle("hidden", editMode); + deleteBtn.classList.toggle("hidden", !editMode); + cancelBtn.classList.toggle("hidden", !editMode); + downloadLink.classList.add("hidden"); + renderList(); +} + +function renderList() { + listEl.innerHTML = ""; + items.forEach((item) => { + const li = document.createElement("li"); + li.className = "tts-item"; + + if (editMode) { + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.checked = selectedIds.has(item.id); + checkbox.addEventListener("click", (event) => { + event.stopPropagation(); + if (checkbox.checked) { + selectedIds.add(item.id); + } else { + selectedIds.delete(item.id); + } + }); + li.appendChild(checkbox); + } else { + const bullet = document.createElement("span"); + bullet.textContent = "•"; + bullet.className = "bullet"; + li.appendChild(bullet); + } + + const label = document.createElement("span"); + label.textContent = item.display_time; + label.className = "item-label"; + li.appendChild(label); + + li.addEventListener("click", () => handleItemClick(item)); + listEl.appendChild(li); + }); +} + +async function loadList() { + const res = await fetch("/api/tts"); + items = await res.json(); + renderList(); +} + +async function handleSave() { + const text = (textInput.value || "").trim(); + if (text.length < 11) { + alert("10개 글자 이상이어야 합니다"); + return; + } + + const res = await fetch("/api/tts", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text }), + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({})); + alert(err.detail || "저장에 실패했습니다."); + return; + } + + const created = await res.json(); + items.unshift(created); + renderList(); +} + +async function handleItemClick(item) { + if (editMode) { + if (selectedIds.has(item.id)) { + selectedIds.delete(item.id); + } else { + selectedIds.add(item.id); + } + renderList(); + return; + } + + const res = await fetch(`/api/tts/${item.id}`); + if (!res.ok) { + alert("항목을 불러오지 못했습니다."); + return; + } + + const data = await res.json(); + textInput.value = data.text || ""; + downloadLink.href = data.download_url; + downloadLink.classList.remove("hidden"); + downloadLink.click(); +} + +async function handleDelete() { + const ids = Array.from(selectedIds); + if (ids.length === 0) { + alert("삭제할 항목을 선택하세요."); + return; + } + + const res = await fetch("/api/tts", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ids }), + }); + + if (!res.ok) { + alert("삭제에 실패했습니다."); + return; + } + + const data = await res.json(); + const deletedSet = new Set(data.deleted || []); + items = items.filter((item) => !deletedSet.has(item.id)); + setEditMode(false); +} + +saveBtn.addEventListener("click", handleSave); +editBtn.addEventListener("click", () => setEditMode(true)); +cancelBtn.addEventListener("click", () => setEditMode(false)); +deleteBtn.addEventListener("click", handleDelete); + +loadList(); diff --git a/client/static/styles.css b/client/static/styles.css new file mode 100644 index 0000000..1081ed8 --- /dev/null +++ b/client/static/styles.css @@ -0,0 +1,114 @@ +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: "Apple SD Gothic Neo", "Malgun Gothic", sans-serif; + background: #f4f4f4; +} + +.container { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 24px; + padding: 24px; + height: 100vh; +} + +.panel { + background: #ffffff; + border: 1px solid #d2d2d2; + padding: 20px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.panel-header { + font-weight: 700; + font-size: 18px; + color: #444; +} + +textarea { + width: 100%; + resize: none; + padding: 12px; + border: 1px solid #c9c9c9; + border-radius: 4px; + font-size: 16px; + line-height: 1.5; + flex: 1; +} + +button { + border: none; + padding: 12px 18px; + font-size: 16px; + cursor: pointer; + border-radius: 4px; +} + +button.primary { + background: #1f5f7a; + color: #ffffff; +} + +button.secondary { + background: #4f9acb; + color: #ffffff; +} + +button.danger { + background: #c84040; + color: #ffffff; +} + +.tts-list { + list-style: none; + padding: 0; + margin: 0; + flex: 1; + overflow-y: auto; +} + +.tts-item { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 6px; + cursor: pointer; + border-bottom: 1px solid #eee; +} + +.tts-item:hover { + background: #f1f6f9; +} + +.bullet { + font-size: 18px; + color: #555; +} + +.item-label { + font-size: 15px; + color: #333; +} + +.right-actions { + display: flex; + gap: 12px; + justify-content: flex-end; +} + +.hidden { + display: none; +} + +#download-link { + text-align: right; + color: #1f5f7a; + text-decoration: none; + font-size: 14px; +} diff --git a/client/templates/index.html b/client/templates/index.html new file mode 100644 index 0000000..373f31a --- /dev/null +++ b/client/templates/index.html @@ -0,0 +1,32 @@ + + + + + + TTS 저장/조회/삭제 + + + +
+
+
입력 텍스트
+ + +
+ +
+
파일리스트
+
    + +
    + + + +
    + +
    +
    + + + + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2eb1168 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +fastapi +uvicorn +python-dotenv +psycopg2-binary +pyttsx3 +jinja2 diff --git a/server/__init__.py b/server/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/server/__init__.py @@ -0,0 +1 @@ + diff --git a/server/db.py b/server/db.py new file mode 100644 index 0000000..bbbb78a --- /dev/null +++ b/server/db.py @@ -0,0 +1,138 @@ +import os +from typing import List, Optional, Dict, Any + +import psycopg2 +import psycopg2.extras + + +def get_conn(): + user = os.getenv("DB_USER") + password = os.getenv("DB_PASSWORD") + if not user or not password: + raise RuntimeError("DB_USER 또는 DB_PASSWORD가 설정되지 않았습니다.") + + return psycopg2.connect( + host="ncue.net", + port=5432, + dbname="tts", + user=user, + password=password, + ) + + +def init_db(): + with get_conn() as conn: + with conn.cursor() as cur: + cur.execute( + """ + CREATE TABLE IF NOT EXISTS tts_items ( + id SERIAL PRIMARY KEY, + text TEXT NOT NULL, + filename TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + """ + ) + cur.execute( + """ + CREATE INDEX IF NOT EXISTS tts_items_created_at_idx + ON tts_items (created_at DESC); + """ + ) + conn.commit() + + +def create_item(text: str) -> Dict[str, Any]: + with get_conn() as conn: + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + cur.execute( + """ + INSERT INTO tts_items (text) + VALUES (%s) + RETURNING id, created_at; + """, + (text,), + ) + row = cur.fetchone() + conn.commit() + return row + + +def update_filename(tts_id: int, filename: str) -> None: + with get_conn() as conn: + with conn.cursor() as cur: + cur.execute( + """ + UPDATE tts_items + SET filename = %s + WHERE id = %s; + """, + (filename, tts_id), + ) + conn.commit() + + +def list_items() -> List[Dict[str, Any]]: + with get_conn() as conn: + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + cur.execute( + """ + SELECT id, created_at, filename + FROM tts_items + ORDER BY created_at DESC; + """ + ) + rows = cur.fetchall() + return rows + + +def get_item(tts_id: int) -> Optional[Dict[str, Any]]: + with get_conn() as conn: + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + cur.execute( + """ + SELECT id, text, filename, created_at + FROM tts_items + WHERE id = %s; + """, + (tts_id,), + ) + row = cur.fetchone() + return row + + +def delete_items(ids: List[int]) -> List[Dict[str, Any]]: + with get_conn() as conn: + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + cur.execute( + """ + SELECT id, filename + FROM tts_items + WHERE id = ANY(%s); + """, + (ids,), + ) + rows = cur.fetchall() + + cur.execute( + """ + DELETE FROM tts_items + WHERE id = ANY(%s); + """, + (ids,), + ) + conn.commit() + return rows + + +def delete_item_by_id(tts_id: int) -> None: + with get_conn() as conn: + with conn.cursor() as cur: + cur.execute( + """ + DELETE FROM tts_items + WHERE id = %s; + """, + (tts_id,), + ) + conn.commit() diff --git a/server/main.py b/server/main.py new file mode 100644 index 0000000..37fd7ab --- /dev/null +++ b/server/main.py @@ -0,0 +1,170 @@ +from pathlib import Path +from typing import List + +from dotenv import load_dotenv +from fastapi import FastAPI, HTTPException, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from pydantic import BaseModel + +from .db import ( + init_db, + create_item, + update_filename, + list_items, + get_item, + delete_items, + delete_item_by_id, +) +from .tts_service import text_to_mp3 + +BASE_DIR = Path(__file__).resolve().parent +ROOT_DIR = BASE_DIR.parent +CLIENT_DIR = ROOT_DIR / "client" +RESOURCES_DIR = ROOT_DIR / "resources" + +# 프로젝트 루트의 .env를 명시적으로 로드 +load_dotenv(dotenv_path=ROOT_DIR / ".env") + +app = FastAPI() + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.mount("/static", StaticFiles(directory=str(CLIENT_DIR / "static")), name="static") +templates = Jinja2Templates(directory=str(CLIENT_DIR / "templates")) + + +class TtsCreateRequest(BaseModel): + text: str + + +class TtsDeleteRequest(BaseModel): + ids: List[int] + + +def format_display_time(dt): + # 한국 표기 형식으로 변환 + local_dt = dt.astimezone() + return local_dt.strftime("%Y년 %m월 %d일 %H:%M:%S") + + +def ensure_resources_dir(): + # mp3 저장 디렉토리 보장 + RESOURCES_DIR.mkdir(parents=True, exist_ok=True) + + +@app.on_event("startup") +def on_startup(): + ensure_resources_dir() + init_db() + + +@app.get("/") +def index(request: Request): + return templates.TemplateResponse("index.html", {"request": request}) + + +@app.get("/api/tts") +def api_list_tts(): + rows = list_items() + return [ + { + "id": row["id"], + "created_at": row["created_at"].isoformat(), + "display_time": format_display_time(row["created_at"]), + "filename": row["filename"], + } + for row in rows + ] + + +@app.post("/api/tts") +def api_create_tts(payload: TtsCreateRequest): + text = (payload.text or "").strip() + if len(text) < 11: + raise HTTPException(status_code=400, detail="텍스트는 11글자 이상이어야 합니다.") + + created = create_item(text) + tts_id = created["id"] + created_at = created["created_at"] + + timestamp = created_at.astimezone().strftime("%Y%m%d_%H%M%S") + filename = f"tts_{tts_id}_{timestamp}.mp3" + mp3_path = RESOURCES_DIR / filename + + try: + text_to_mp3(text=text, mp3_path=str(mp3_path)) + except Exception as exc: + delete_item_by_id(tts_id) + raise HTTPException(status_code=500, detail=str(exc)) from exc + + update_filename(tts_id, filename) + + return { + "id": tts_id, + "created_at": created_at.isoformat(), + "display_time": format_display_time(created_at), + "filename": filename, + } + + +@app.get("/api/tts/{tts_id}") +def api_get_tts(tts_id: int): + row = get_item(tts_id) + if not row: + raise HTTPException(status_code=404, detail="해당 항목이 없습니다.") + + return { + "id": row["id"], + "text": row["text"], + "created_at": row["created_at"].isoformat(), + "display_time": format_display_time(row["created_at"]), + "filename": row["filename"], + "download_url": f"/api/tts/{row['id']}/download", + } + + +@app.get("/api/tts/{tts_id}/download") +def api_download_tts(tts_id: int): + row = get_item(tts_id) + if not row or not row["filename"]: + raise HTTPException(status_code=404, detail="파일이 없습니다.") + + file_path = RESOURCES_DIR / row["filename"] + if not file_path.exists(): + raise HTTPException(status_code=404, detail="파일이 없습니다.") + + return FileResponse( + path=str(file_path), + media_type="audio/mpeg", + filename=row["filename"], + ) + + +@app.delete("/api/tts") +def api_delete_tts(payload: TtsDeleteRequest): + ids = [int(i) for i in payload.ids if isinstance(i, int) or str(i).isdigit()] + if not ids: + raise HTTPException(status_code=400, detail="삭제할 항목이 없습니다.") + + deleted_rows = delete_items(ids) + deleted_ids = [] + for row in deleted_rows: + deleted_ids.append(row["id"]) + if row.get("filename"): + file_path = RESOURCES_DIR / row["filename"] + if file_path.exists(): + try: + file_path.unlink() + except OSError: + pass + + return {"deleted": deleted_ids} diff --git a/server/run.sh b/server/run.sh new file mode 100644 index 0000000..fe89171 --- /dev/null +++ b/server/run.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd /home/dsyoon/workspace/tts + +PORT="${PORT:-8018}" + +if lsof -ti tcp:"${PORT}" >/dev/null 2>&1; then + echo "Stopping existing server on port ${PORT}..." + lsof -ti tcp:"${PORT}" | xargs -r kill -9 + sleep 1 +fi + +python -m pip install -r requirements.txt + +PORT="${PORT}" nohup uvicorn server.main:app --host 0.0.0.0 --port "${PORT}" > server.log 2>&1 & +echo "Server started (PID: $!). Logs: server.log" diff --git a/server/tts_service.py b/server/tts_service.py new file mode 100644 index 0000000..a92b3c4 --- /dev/null +++ b/server/tts_service.py @@ -0,0 +1,42 @@ +import os +import subprocess +import tempfile +from pathlib import Path + +import pyttsx3 + + +def text_to_mp3(text: str, mp3_path: str) -> None: + if not text: + raise RuntimeError("텍스트가 비어 있습니다.") + + mp3_target = Path(mp3_path) + mp3_target.parent.mkdir(parents=True, exist_ok=True) + + engine = pyttsx3.init() + wav_fd, wav_path = tempfile.mkstemp(suffix=".wav") + os.close(wav_fd) + + try: + # pyttsx3로 wav 생성 후 ffmpeg로 mp3 변환 + engine.save_to_file(text, wav_path) + engine.runAndWait() + + subprocess.run( + ["ffmpeg", "-y", "-i", wav_path, str(mp3_target)], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + if not mp3_target.exists(): + raise RuntimeError("mp3 파일 생성에 실패했습니다.") + except subprocess.CalledProcessError as exc: + raise RuntimeError("ffmpeg 변환에 실패했습니다.") from exc + except OSError as exc: + raise RuntimeError("파일 생성 권한 또는 경로 오류입니다.") from exc + finally: + try: + os.remove(wav_path) + except OSError: + pass