From febb3f3ee4f4f013fac96154a53db6ea60f4f778 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 25 Aug 2026 19:46:20 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=A0=84=20=EC=97=94=EC=A7=84=20?= =?UTF-8?q?=EC=86=8D=EB=8F=84=20=EC=A1=B0=EC=A0=88=EC=9D=84=20=ED=94=BC?= =?UTF-8?q?=EC=B9=98=EB=B3=B4=EC=A1=B4=20=ED=83=80=EC=9E=84=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=A0=88=EC=B9=98(Rubberband=20R3)=EB=A1=9C=20?= =?UTF-8?q?=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Supertonic/OuteTTS도 자연 속도 합성 후 Rubberband로 배속 → 고배속에서 목소리 안 뭉개짐 - Typecast 등 AI보이스가 쓰는 방식(pitch-preserving TSM)과 동일 - pyrubberband 워커 의존성 추가(rubberband-cli는 이미 이미지에 포함) --- outetts_worker/requirements.txt | 1 + outetts_worker/worker.py | 47 +++++++++++++++++++++++------- supertonic_worker/requirements.txt | 1 + supertonic_worker/worker.py | 36 ++++++++++++++++++++++- 4 files changed, 73 insertions(+), 12 deletions(-) diff --git a/outetts_worker/requirements.txt b/outetts_worker/requirements.txt index 4ff778f..ae98eb3 100644 --- a/outetts_worker/requirements.txt +++ b/outetts_worker/requirements.txt @@ -5,3 +5,4 @@ soundfile librosa fastapi==0.115.6 uvicorn==0.30.0 +pyrubberband==0.4.0 diff --git a/outetts_worker/worker.py b/outetts_worker/worker.py index 3298c98..7d2fa80 100644 --- a/outetts_worker/worker.py +++ b/outetts_worker/worker.py @@ -8,6 +8,8 @@ from __future__ import annotations import io +import shutil + import numpy as np import soundfile as sf @@ -16,6 +18,35 @@ try: except Exception: # pragma: no cover librosa = None +try: + import pyrubberband as prb +except Exception: # pragma: no cover + prb = None + + +def time_stretch(audio: np.ndarray, sr: int, rate: float) -> np.ndarray: + """피치 보존 배속(Rubberband R3 → librosa 폴백). rate>1 = 빠르게.""" + if abs(rate - 1.0) < 1e-3: + return audio + audio = np.asarray(audio, dtype=np.float32) + if prb is not None and shutil.which("rubberband"): + try: + return np.asarray( + prb.time_stretch(audio, sr, rate, rbargs={"--engine": "finer"}), + dtype=np.float32, + ) + except Exception: + try: + return np.asarray(prb.time_stretch(audio, sr, rate), dtype=np.float32) + except Exception: + pass + if librosa is not None: + try: + return librosa.effects.time_stretch(audio, rate=rate).astype(np.float32) + except Exception: + pass + return audio + from fastapi import FastAPI from fastapi.responses import Response from pydantic import BaseModel @@ -87,17 +118,11 @@ def synth(req: SynthReq) -> Response: audio = out.audio.cpu().numpy().reshape(-1).astype(np.float32) sr = int(out.sr) - if librosa is not None: - if abs(speed - 1.0) > 1e-3: - try: - audio = librosa.effects.time_stretch(audio, rate=speed).astype(np.float32) - except Exception: - try: - audio = librosa.effects.time_stretch(audio, speed).astype(np.float32) - except Exception: - pass - if abs(pitch) > 1e-3: - audio = librosa.effects.pitch_shift(audio, sr=sr, n_steps=pitch) + # 피치 보존 배속(고배속에서도 목소리 안 뭉개짐) + if abs(speed - 1.0) > 1e-3: + audio = time_stretch(audio, sr, speed) + if abs(pitch) > 1e-3 and librosa is not None: + audio = librosa.effects.pitch_shift(audio, sr=sr, n_steps=pitch) buf = io.BytesIO() sf.write(buf, audio, sr, format="WAV", subtype="PCM_16") diff --git a/supertonic_worker/requirements.txt b/supertonic_worker/requirements.txt index c083203..1797ae8 100644 --- a/supertonic_worker/requirements.txt +++ b/supertonic_worker/requirements.txt @@ -2,3 +2,4 @@ supertonic==1.3.1 fastapi==0.115.6 uvicorn==0.30.0 +pyrubberband==0.4.0 diff --git a/supertonic_worker/worker.py b/supertonic_worker/worker.py index 1007670..c60cc1a 100644 --- a/supertonic_worker/worker.py +++ b/supertonic_worker/worker.py @@ -9,6 +9,8 @@ from __future__ import annotations import io +import shutil + import numpy as np import soundfile as sf @@ -17,6 +19,35 @@ try: except Exception: # pragma: no cover librosa = None +try: + import pyrubberband as prb +except Exception: # pragma: no cover + prb = None + + +def time_stretch(audio: np.ndarray, sr: int, rate: float) -> np.ndarray: + """피치 보존 배속(Rubberband R3 → librosa 폴백). rate>1 = 빠르게.""" + if abs(rate - 1.0) < 1e-3: + return audio + audio = np.asarray(audio, dtype=np.float32) + if prb is not None and shutil.which("rubberband"): + try: + return np.asarray( + prb.time_stretch(audio, sr, rate, rbargs={"--engine": "finer"}), + dtype=np.float32, + ) + except Exception: + try: + return np.asarray(prb.time_stretch(audio, sr, rate), dtype=np.float32) + except Exception: + pass + if librosa is not None: + try: + return librosa.effects.time_stretch(audio, rate=rate).astype(np.float32) + except Exception: + pass + return audio + from fastapi import FastAPI from fastapi.responses import Response from pydantic import BaseModel @@ -79,9 +110,12 @@ def synth(req: SynthReq) -> Response: speed = float(max(0.5, min(2.0, req.speed))) pitch = float(max(-12.0, min(12.0, req.pitch))) - wav, _dur = tts.synthesize(text, voice_style=style, lang=lang, speed=speed) + # 자연 속도로 합성 후 피치 보존 배속(고배속에서도 목소리 안 뭉개짐) + wav, _dur = tts.synthesize(text, voice_style=style, lang=lang, speed=1.0) audio = np.asarray(wav).reshape(-1).astype(np.float32) + if abs(speed - 1.0) > 1e-3: + audio = time_stretch(audio, SR, speed) if abs(pitch) > 1e-3 and librosa is not None: audio = librosa.effects.pitch_shift(audio, sr=SR, n_steps=pitch)