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 listEl = document.getElementById("tts-list");
|
||||||
const textInput = document.getElementById("text-input");
|
const textInput = document.getElementById("text-input");
|
||||||
const saveBtn = document.getElementById("save-btn");
|
const saveBtn = document.getElementById("save-btn");
|
||||||
|
const voiceSelect = document.getElementById("voice-select");
|
||||||
const editBtn = document.getElementById("edit-btn");
|
const editBtn = document.getElementById("edit-btn");
|
||||||
const deleteBtn = document.getElementById("delete-btn");
|
const deleteBtn = document.getElementById("delete-btn");
|
||||||
const cancelBtn = document.getElementById("cancel-btn");
|
const cancelBtn = document.getElementById("cancel-btn");
|
||||||
@@ -111,6 +112,7 @@ async function loadList() {
|
|||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
const text = (textInput.value || "").trim();
|
const text = (textInput.value || "").trim();
|
||||||
|
const voice = (voiceSelect?.value || "male").trim();
|
||||||
if (text.length < 11) {
|
if (text.length < 11) {
|
||||||
alert("10개 글자 이상이어야 합니다");
|
alert("10개 글자 이상이어야 합니다");
|
||||||
return;
|
return;
|
||||||
@@ -120,7 +122,7 @@ async function handleSave() {
|
|||||||
const res = await fetch("/api/tts", {
|
const res = await fetch("/api/tts", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ text }),
|
body: JSON.stringify({ text, voice }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|||||||
@@ -42,6 +42,21 @@ textarea {
|
|||||||
flex: 1;
|
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 {
|
button {
|
||||||
border: none;
|
border: none;
|
||||||
padding: 12px 18px;
|
padding: 12px 18px;
|
||||||
|
|||||||
@@ -11,7 +11,13 @@
|
|||||||
<section class="panel left">
|
<section class="panel left">
|
||||||
<div class="panel-header">입력 텍스트</div>
|
<div class="panel-header">입력 텍스트</div>
|
||||||
<textarea id="text-input" rows="16" placeholder="텍스트를 입력하세요"></textarea>
|
<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" class="progress-wrap hidden" aria-label="저장 진행률">
|
||||||
<div
|
<div
|
||||||
id="save-progress-bar"
|
id="save-progress-bar"
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ templates = Jinja2Templates(directory=str(CLIENT_DIR / "templates"))
|
|||||||
|
|
||||||
class TtsCreateRequest(BaseModel):
|
class TtsCreateRequest(BaseModel):
|
||||||
text: str
|
text: str
|
||||||
|
voice: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class TtsDeleteRequest(BaseModel):
|
class TtsDeleteRequest(BaseModel):
|
||||||
@@ -125,6 +126,7 @@ def api_list_tts():
|
|||||||
@app.post("/api/tts")
|
@app.post("/api/tts")
|
||||||
def api_create_tts(payload: TtsCreateRequest):
|
def api_create_tts(payload: TtsCreateRequest):
|
||||||
text = (payload.text or "").strip()
|
text = (payload.text or "").strip()
|
||||||
|
voice = (payload.voice or "").strip().lower()
|
||||||
if len(text) < 11:
|
if len(text) < 11:
|
||||||
raise HTTPException(status_code=400, detail="텍스트는 11글자 이상이어야 합니다.")
|
raise HTTPException(status_code=400, detail="텍스트는 11글자 이상이어야 합니다.")
|
||||||
|
|
||||||
@@ -137,7 +139,7 @@ def api_create_tts(payload: TtsCreateRequest):
|
|||||||
mp3_path = RESOURCES_DIR / filename
|
mp3_path = RESOURCES_DIR / filename
|
||||||
|
|
||||||
try:
|
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:
|
except Exception as exc:
|
||||||
logger.exception("TTS 생성 실패")
|
logger.exception("TTS 생성 실패")
|
||||||
delete_item_by_id(tts_id)
|
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")
|
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:
|
try:
|
||||||
voices = engine.getProperty("voices") or []
|
voices = engine.getProperty("voices") or []
|
||||||
except Exception:
|
except Exception:
|
||||||
return
|
return
|
||||||
|
|
||||||
for voice in voices:
|
def _voice_info(v):
|
||||||
lang_values = []
|
values = []
|
||||||
if getattr(voice, "languages", None):
|
if getattr(v, "languages", None):
|
||||||
lang_values.extend(voice.languages)
|
values.extend(v.languages)
|
||||||
if getattr(voice, "id", None):
|
if getattr(v, "id", None):
|
||||||
lang_values.append(voice.id)
|
values.append(v.id)
|
||||||
if getattr(voice, "name", None):
|
if getattr(v, "name", None):
|
||||||
lang_values.append(voice.name)
|
values.append(v.name)
|
||||||
|
return " ".join(str(x) for x in values).lower()
|
||||||
|
|
||||||
joined = " ".join(str(v) for v in lang_values).lower()
|
def _is_korean(info: str) -> bool:
|
||||||
if "ko" in joined or "korean" in joined:
|
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:
|
try:
|
||||||
engine.setProperty("voice", voice.id)
|
engine.setProperty("voice", voice.id)
|
||||||
return
|
return
|
||||||
@@ -200,7 +218,7 @@ def _preprocess_text(text: str) -> str:
|
|||||||
return text
|
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:
|
if not text:
|
||||||
raise RuntimeError("텍스트가 비어 있습니다.")
|
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)
|
mp3_target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
tts_engine = os.getenv("TTS_ENGINE", "pyttsx3").strip().lower()
|
tts_engine = os.getenv("TTS_ENGINE", "pyttsx3").strip().lower()
|
||||||
|
voice = (voice or "").strip().lower() or None
|
||||||
wav_fd, wav_path = tempfile.mkstemp(suffix=".wav")
|
wav_fd, wav_path = tempfile.mkstemp(suffix=".wav")
|
||||||
os.close(wav_fd)
|
os.close(wav_fd)
|
||||||
|
|
||||||
@@ -226,7 +245,7 @@ def text_to_mp3(text: str, mp3_path: str) -> None:
|
|||||||
engine.setProperty("volume", 1.0)
|
engine.setProperty("volume", 1.0)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
_select_korean_voice(engine)
|
_select_korean_voice(engine, prefer_female=voice == "female")
|
||||||
# pyttsx3로 wav 생성 후 ffmpeg로 mp3 변환
|
# pyttsx3로 wav 생성 후 ffmpeg로 mp3 변환
|
||||||
engine.save_to_file(text, wav_path)
|
engine.save_to_file(text, wav_path)
|
||||||
engine.runAndWait()
|
engine.runAndWait()
|
||||||
|
|||||||
Reference in New Issue
Block a user