Compare commits

...

2 Commits

Author SHA1 Message Date
claude
fae52f67c5 fix: 글자 속도를 생성 단계 length_scale(음절 발화 속도)로 변경
- 사후 배속(atempo) 대신 model.tts_to_file(speed=) 로 각 음절(안/녕/하)의
  발음 속도 자체를 조절. 피치 보존, TSM 아티팩트 없음
- _synth_segment/_synth_natural 에 speed 인자 전달, synth_wav 의 사후
  타임스트레치 제거. 단어·문장 간격은 여전히 발화 속도와 독립
2026-08-26 00:20:57 +09:00
claude
dde57aafda tune: 속도/간격 슬라이더 기본값을 범위 가운데로, 양 끝을 최소·최대치로 정렬
- 글자 속도 기본 1.4→1.25(범위 0.5~2.0의 중앙)
- 단어 간격 기본 0→0.25s(범위 0~0.5s의 중앙)
- 문장 간격 기본 0.3→0.75s(범위 0~1.5s의 중앙)
- 슬라이더 스케일 라벨에 최소/최대 수치 표기
2026-08-26 00:16:41 +09:00
3 changed files with 29 additions and 31 deletions

View File

@@ -199,14 +199,15 @@ class MeloEngine(BaseEngine):
return list(spk2id.values())[0] return list(spk2id.values())[0]
def _synth_segment( def _synth_segment(
self, text: str, lang: str, speaker: str | None self, text: str, lang: str, speaker: str | None, speed: float = 1.0
) -> tuple[np.ndarray, int]: ) -> tuple[np.ndarray, int]:
# 항상 자연 속도(1.0)로 합성 — 속도는 사후 타임스트레치로 처리해 발음 유지 # 글자(음절) 발화 속도는 모델 length_scale(=1/speed)로 생성 단계에서 조절.
# 각 음절의 발음 자체가 빨라/느려지며(피치 보존), 사후 배속이 아니다.
model = self._get_model(lang) model = self._get_model(lang)
speaker_id = self._resolve_speaker(model, lang, speaker) speaker_id = self._resolve_speaker(model, lang, speaker)
with self._global_lock: with self._global_lock:
audio = model.tts_to_file( audio = model.tts_to_file(
text, speaker_id, output_path=None, speed=1.0, quiet=True text, speaker_id, output_path=None, speed=speed, quiet=True
) )
sr = model.hps.data.sampling_rate sr = model.hps.data.sampling_rate
return np.asarray(audio, dtype=np.float32), sr return np.asarray(audio, dtype=np.float32), sr
@@ -273,11 +274,12 @@ class MeloEngine(BaseEngine):
return np.asarray(out, dtype=np.float32) return np.asarray(out, dtype=np.float32)
def _synth_natural( def _synth_natural(
self, text: str, language: str, speaker: str | None self, text: str, language: str, speaker: str | None, speed: float = 1.0
) -> tuple[np.ndarray, int]: ) -> tuple[np.ndarray, int]:
"""자연 속도(1.0)로 텍스트 한 조각을 합성. AUTO 모드는 언어별로 나눠 합성. """텍스트 한 조각을 합성. AUTO 모드는 언어별로 나눠 합성.
속도/문장·단어 간격 조절은 이 결과 위에서 처리한다(여기서는 하지 않음). speed 는 모델 length_scale 로 각 음절의 발화 속도를 조절한다(생성 단계).
문장·단어 간격은 이 결과 위에서 삽입한다(여기서는 하지 않음).
""" """
if language == "AUTO": if language == "AUTO":
# 영어 구간엔 사용자가 고른 억양, 한국어 구간엔 KR 음성 # 영어 구간엔 사용자가 고른 억양, 한국어 구간엔 KR 음성
@@ -291,7 +293,7 @@ class MeloEngine(BaseEngine):
gap = None gap = None
for seg_lang, seg_text in parts: for seg_lang, seg_text in parts:
seg_spk = spk_by_lang.get(seg_lang, en_speaker) seg_spk = spk_by_lang.get(seg_lang, en_speaker)
audio, seg_sr = self._synth_segment(seg_text, seg_lang, seg_spk) audio, seg_sr = self._synth_segment(seg_text, seg_lang, seg_spk, speed)
if sr is None: if sr is None:
sr = seg_sr sr = seg_sr
gap = np.zeros(int(sr * 0.06), dtype=np.float32) gap = np.zeros(int(sr * 0.06), dtype=np.float32)
@@ -304,7 +306,7 @@ class MeloEngine(BaseEngine):
audios.append(audio) audios.append(audio)
audio = np.concatenate(audios) if audios else np.zeros(1, dtype=np.float32) audio = np.concatenate(audios) if audios else np.zeros(1, dtype=np.float32)
return audio, (sr or self._get_model(DEFAULT_LANG).hps.data.sampling_rate) return audio, (sr or self._get_model(DEFAULT_LANG).hps.data.sampling_rate)
return self._synth_segment(text, language, speaker) return self._synth_segment(text, language, speaker, speed)
# ---- 합성 ----------------------------------------------------- # ---- 합성 -----------------------------------------------------
def synth_wav( def synth_wav(
@@ -318,10 +320,11 @@ class MeloEngine(BaseEngine):
sentence_gap: float | None = None, sentence_gap: float | None = None,
) -> bytes: ) -> bytes:
"""세 가지를 독립적으로 조절: """세 가지를 독립적으로 조절:
- speed: 글자(음소) 발화 속도 — 자연 합성 후 타임스트레치(피치 보존) - speed: 글자(음절) 발화 속도 — 모델 length_scale 로 각 음절의 발음
속도를 생성 단계에서 조절(피치 보존, 사후 배속 아님)
- word_gap: 단어 사이 추가 무음(초). >0 이면 어절 단위로 나눠 합성 - word_gap: 단어 사이 추가 무음(초). >0 이면 어절 단위로 나눠 합성
- sentence_gap: 문장 사이 무음(초) - sentence_gap: 문장 사이 무음(초)
간격은 타임스트레치의 영향을 받지 않아 글자속도와 독립적으로 유지된다. 간격은 발화 속도와 무관하게 지정한 초만큼 유지되어 서로 독립적이다.
""" """
text = (text or "").strip() text = (text or "").strip()
if not text: if not text:
@@ -338,11 +341,6 @@ class MeloEngine(BaseEngine):
if language in ("AUTO", "KR"): if language in ("AUTO", "KR"):
text = normalize_korean(text) text = normalize_korean(text)
def stretch(a: np.ndarray, sr: int) -> np.ndarray:
if abs(speed - 1.0) > 1e-3:
return self._time_stretch(a, sr, speed)
return a
sentences = split_sentences(text) or [text] sentences = split_sentences(text) or [text]
sr: int | None = None sr: int | None = None
pieces: list[np.ndarray] = [] pieces: list[np.ndarray] = []
@@ -353,10 +351,9 @@ class MeloEngine(BaseEngine):
words = sent.split() words = sent.split()
wpieces: list[np.ndarray] = [] wpieces: list[np.ndarray] = []
for wi, word in enumerate(words): for wi, word in enumerate(words):
a, s = self._synth_natural(word, language, speaker) a, s = self._synth_natural(word, language, speaker, speed)
if sr is None: if sr is None:
sr = s sr = s
a = stretch(a, sr)
if wi > 0: if wi > 0:
wpieces.append(np.zeros(int(sr * word_gap), dtype=np.float32)) wpieces.append(np.zeros(int(sr * word_gap), dtype=np.float32))
wpieces.append(a) wpieces.append(a)
@@ -366,10 +363,10 @@ class MeloEngine(BaseEngine):
else np.zeros(1, dtype=np.float32) else np.zeros(1, dtype=np.float32)
) )
else: else:
a, s = self._synth_natural(sent, language, speaker) a, s = self._synth_natural(sent, language, speaker, speed)
if sr is None: if sr is None:
sr = s sr = s
sent_audio = stretch(a, sr) sent_audio = a
if pieces and sentence_gap > 1e-4: if pieces and sentence_gap > 1e-4:
pieces.append(np.zeros(int(sr * sentence_gap), dtype=np.float32)) pieces.append(np.zeros(int(sr * sentence_gap), dtype=np.float32))

View File

@@ -39,10 +39,11 @@ class TTSRequest(BaseModel):
engine: str | None = Field(None, description="엔진 ID (melo / coqui-kss 등)") engine: str | None = Field(None, description="엔진 ID (melo / coqui-kss 등)")
language: str = Field("KR", description="언어 코드") language: str = Field("KR", description="언어 코드")
speaker: str | None = Field(None, description="화자/모델 ID") speaker: str | None = Field(None, description="화자/모델 ID")
speed: float = Field(1.4, ge=0.5, le=2.0, description="글자(음소) 발화 속도") # 기본값은 각 범위의 가운데. 슬라이더 양 끝이 조절 가능한 최소/최대치.
speed: float = Field(1.25, ge=0.5, le=2.0, description="글자(음소) 발화 속도(0.5~2.0, 기본 1.25)")
pitch: float = Field(0.0, ge=-12.0, le=12.0, description="피치(반음)") pitch: float = Field(0.0, ge=-12.0, le=12.0, description="피치(반음)")
word_gap: float = Field(0.0, ge=0.0, le=1.0, description="단어 사이 무음(초)") word_gap: float = Field(0.25, ge=0.0, le=0.5, description="단어 사이 무음 초(0~0.5, 기본 0.25)")
sentence_gap: float = Field(0.3, ge=0.0, le=2.0, description="문장 사이 무음(초)") sentence_gap: float = Field(0.75, ge=0.0, le=1.5, description="문장 사이 무음 초(0~1.5, 기본 0.75)")
@app.on_event("startup") @app.on_event("startup")

View File

@@ -43,26 +43,26 @@
<div class="slider-row"> <div class="slider-row">
<div class="slider-head"> <div class="slider-head">
<span>글자 속도</span> <span>글자 속도</span>
<span class="slider-val" id="speedVal">1.40x</span> <span class="slider-val" id="speedVal">1.25x</span>
</div> </div>
<input type="range" id="speed" min="0.5" max="2.0" step="0.05" value="1.4" /> <input type="range" id="speed" min="0.5" max="2.0" step="0.05" value="1.25" />
<div class="slider-scale"><span>느리게</span><span>빠르게</span></div> <div class="slider-scale"><span>0.5x 느리게</span><span>2.0x 빠르게</span></div>
</div> </div>
<div class="slider-row"> <div class="slider-row">
<div class="slider-head"> <div class="slider-head">
<span>단어 간격</span> <span>단어 간격</span>
<span class="slider-val" id="wordGapVal">0 ms</span> <span class="slider-val" id="wordGapVal">250 ms</span>
</div> </div>
<input type="range" id="wordGap" min="0" max="1.0" step="0.02" value="0" /> <input type="range" id="wordGap" min="0" max="0.5" step="0.01" value="0.25" />
<div class="slider-scale"><span>붙여서</span><span>띄어서</span></div> <div class="slider-scale"><span>0 ms 붙여서</span><span>500 ms 띄어서</span></div>
</div> </div>
<div class="slider-row"> <div class="slider-row">
<div class="slider-head"> <div class="slider-head">
<span>문장 간격</span> <span>문장 간격</span>
<span class="slider-val" id="sentGapVal">0.30 s</span> <span class="slider-val" id="sentGapVal">0.75 s</span>
</div> </div>
<input type="range" id="sentGap" min="0" max="1.5" step="0.05" value="0.3" /> <input type="range" id="sentGap" min="0" max="1.5" step="0.05" value="0.75" />
<div class="slider-scale"><span>짧게</span><span>길게</span></div> <div class="slider-scale"><span>0 s 짧게</span><span>1.5 s 길게</span></div>
</div> </div>
<div class="slider-row"> <div class="slider-row">
<div class="slider-head"> <div class="slider-head">