Add voice selection control
Expose a voice selector next to the save button and pass the choice to TTS so pyttsx3 can prefer a female voice.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
const listEl = document.getElementById("tts-list");
|
||||
const textInput = document.getElementById("text-input");
|
||||
const saveBtn = document.getElementById("save-btn");
|
||||
const voiceSelect = document.getElementById("voice-select");
|
||||
const editBtn = document.getElementById("edit-btn");
|
||||
const deleteBtn = document.getElementById("delete-btn");
|
||||
const cancelBtn = document.getElementById("cancel-btn");
|
||||
@@ -111,6 +112,7 @@ async function loadList() {
|
||||
|
||||
async function handleSave() {
|
||||
const text = (textInput.value || "").trim();
|
||||
const voice = (voiceSelect?.value || "male").trim();
|
||||
if (text.length < 11) {
|
||||
alert("10개 글자 이상이어야 합니다");
|
||||
return;
|
||||
@@ -120,7 +122,7 @@ async function handleSave() {
|
||||
const res = await fetch("/api/tts", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ text }),
|
||||
body: JSON.stringify({ text, voice }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
|
||||
@@ -42,6 +42,21 @@ textarea {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.save-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.voice-select {
|
||||
border: 1px solid #c9c9c9;
|
||||
border-radius: 4px;
|
||||
padding: 10px 12px;
|
||||
font-size: 14px;
|
||||
background: #ffffff;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
button {
|
||||
border: none;
|
||||
padding: 12px 18px;
|
||||
|
||||
@@ -11,7 +11,13 @@
|
||||
<section class="panel left">
|
||||
<div class="panel-header">입력 텍스트</div>
|
||||
<textarea id="text-input" rows="16" placeholder="텍스트를 입력하세요"></textarea>
|
||||
<button id="save-btn" class="primary">mp3 저장</button>
|
||||
<div class="save-row">
|
||||
<button id="save-btn" class="primary">mp3 변환</button>
|
||||
<select id="voice-select" class="voice-select" aria-label="음성 선택">
|
||||
<option value="male">남성#1</option>
|
||||
<option value="female">여성#1</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="save-progress" class="progress-wrap hidden" aria-label="저장 진행률">
|
||||
<div
|
||||
id="save-progress-bar"
|
||||
|
||||
@@ -48,6 +48,7 @@ templates = Jinja2Templates(directory=str(CLIENT_DIR / "templates"))
|
||||
|
||||
class TtsCreateRequest(BaseModel):
|
||||
text: str
|
||||
voice: str | None = None
|
||||
|
||||
|
||||
class TtsDeleteRequest(BaseModel):
|
||||
@@ -125,6 +126,7 @@ def api_list_tts():
|
||||
@app.post("/api/tts")
|
||||
def api_create_tts(payload: TtsCreateRequest):
|
||||
text = (payload.text or "").strip()
|
||||
voice = (payload.voice or "").strip().lower()
|
||||
if len(text) < 11:
|
||||
raise HTTPException(status_code=400, detail="텍스트는 11글자 이상이어야 합니다.")
|
||||
|
||||
@@ -137,7 +139,7 @@ def api_create_tts(payload: TtsCreateRequest):
|
||||
mp3_path = RESOURCES_DIR / filename
|
||||
|
||||
try:
|
||||
text_to_mp3(text=text, mp3_path=str(mp3_path))
|
||||
text_to_mp3(text=text, mp3_path=str(mp3_path), voice=voice)
|
||||
except Exception as exc:
|
||||
logger.exception("TTS 생성 실패")
|
||||
delete_item_by_id(tts_id)
|
||||
|
||||
@@ -111,23 +111,41 @@ def _text_to_wav_mms(text: str, wav_path: str) -> None:
|
||||
sf.write(wav_path, audio, sample_rate, subtype="PCM_16")
|
||||
|
||||
|
||||
def _select_korean_voice(engine: pyttsx3.Engine) -> None:
|
||||
def _select_korean_voice(engine: pyttsx3.Engine, prefer_female: bool = False) -> None:
|
||||
try:
|
||||
voices = engine.getProperty("voices") or []
|
||||
except Exception:
|
||||
return
|
||||
|
||||
for voice in voices:
|
||||
lang_values = []
|
||||
if getattr(voice, "languages", None):
|
||||
lang_values.extend(voice.languages)
|
||||
if getattr(voice, "id", None):
|
||||
lang_values.append(voice.id)
|
||||
if getattr(voice, "name", None):
|
||||
lang_values.append(voice.name)
|
||||
def _voice_info(v):
|
||||
values = []
|
||||
if getattr(v, "languages", None):
|
||||
values.extend(v.languages)
|
||||
if getattr(v, "id", None):
|
||||
values.append(v.id)
|
||||
if getattr(v, "name", None):
|
||||
values.append(v.name)
|
||||
return " ".join(str(x) for x in values).lower()
|
||||
|
||||
joined = " ".join(str(v) for v in lang_values).lower()
|
||||
if "ko" in joined or "korean" in joined:
|
||||
def _is_korean(info: str) -> bool:
|
||||
return "ko" in info or "korean" in info
|
||||
|
||||
def _is_female(info: str) -> bool:
|
||||
return any(token in info for token in ["female", "woman", "girl", "여성", "여자"])
|
||||
|
||||
if prefer_female:
|
||||
for voice in voices:
|
||||
info = _voice_info(voice)
|
||||
if _is_korean(info) and _is_female(info):
|
||||
try:
|
||||
engine.setProperty("voice", voice.id)
|
||||
return
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
for voice in voices:
|
||||
info = _voice_info(voice)
|
||||
if _is_korean(info):
|
||||
try:
|
||||
engine.setProperty("voice", voice.id)
|
||||
return
|
||||
@@ -200,7 +218,7 @@ def _preprocess_text(text: str) -> str:
|
||||
return text
|
||||
|
||||
|
||||
def text_to_mp3(text: str, mp3_path: str) -> None:
|
||||
def text_to_mp3(text: str, mp3_path: str, voice: Optional[str] = None) -> None:
|
||||
if not text:
|
||||
raise RuntimeError("텍스트가 비어 있습니다.")
|
||||
|
||||
@@ -210,6 +228,7 @@ def text_to_mp3(text: str, mp3_path: str) -> None:
|
||||
mp3_target.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
tts_engine = os.getenv("TTS_ENGINE", "pyttsx3").strip().lower()
|
||||
voice = (voice or "").strip().lower() or None
|
||||
wav_fd, wav_path = tempfile.mkstemp(suffix=".wav")
|
||||
os.close(wav_fd)
|
||||
|
||||
@@ -226,7 +245,7 @@ def text_to_mp3(text: str, mp3_path: str) -> None:
|
||||
engine.setProperty("volume", 1.0)
|
||||
except Exception:
|
||||
pass
|
||||
_select_korean_voice(engine)
|
||||
_select_korean_voice(engine, prefer_female=voice == "female")
|
||||
# pyttsx3로 wav 생성 후 ffmpeg로 mp3 변환
|
||||
engine.save_to_file(text, wav_path)
|
||||
engine.runAndWait()
|
||||
|
||||
Reference in New Issue
Block a user