feat(TTS): implement Zall TTS integration with voice selection and error handling

pull/2558/head
MinhVu2711 3 months ago
parent 6a69319ced
commit 6e833be8a2

@ -14,10 +14,10 @@ from utils.console import print_substep
# Load environment variables from .env file # Load environment variables from .env file
load_dotenv() load_dotenv()
OHFREEME_API_URL = os.getenv("OHFREEME_API_URL", "") ZALL_API_URL = os.getenv("ZALL_API_URL", "")
OHFREEME_BASE_URL = os.getenv("OHFREEME_BASE_URL", "") ZALL_BASE_URL = os.getenv("ZALL_BASE_URL", "")
OHFREEME_JWT_TOKEN = os.getenv("OHFREEME_JWT_TOKEN", "") ZALL_JWT_TOKEN = os.getenv("ZALL_JWT_TOKEN", "")
VOICES_FILE = Path(__file__).resolve().parent.parent / "config" / "ohfreeme_voices.json" VOICES_FILE = Path(__file__).resolve().parent.parent / "config" / "zall_voices.json"
MAX_RETRIES = 3 MAX_RETRIES = 3
RATE_LIMIT_WAIT = 20 RATE_LIMIT_WAIT = 20
@ -31,7 +31,7 @@ def _load_voices() -> list[dict]:
return [] return []
class OhFreeMe: class Zall:
# Load list of UserAgent strings for random header # Load list of UserAgent strings for random header
_user_agents = None _user_agents = None
@ -70,7 +70,7 @@ class OhFreeMe:
f.write(audio_bytes) f.write(audio_bytes)
def randomvoice(self) -> dict: def randomvoice(self) -> dict:
lang = settings.config["settings"]["tts"].get("ohfreeme_lang", "vi") lang = settings.config["settings"]["tts"].get("zall_lang", "vi")
filtered = [v for v in self.voices if v["lang"] == lang] filtered = [v for v in self.voices if v["lang"] == lang]
if not filtered: if not filtered:
filtered = self.voices filtered = self.voices
@ -79,8 +79,8 @@ class OhFreeMe:
def _pick_voice(self, random_voice: bool) -> dict: def _pick_voice(self, random_voice: bool) -> dict:
if random_voice: if random_voice:
return self.randomvoice() return self.randomvoice()
lang = settings.config["settings"]["tts"].get("ohfreeme_lang", "vi") lang = settings.config["settings"]["tts"].get("zall_lang", "vi")
gender = settings.config["settings"]["tts"].get("ohfreeme_gender", "random") gender = settings.config["settings"]["tts"].get("zall_gender", "random")
candidates = [v for v in self.voices if v["lang"] == lang] candidates = [v for v in self.voices if v["lang"] == lang]
if gender != "random": if gender != "random":
candidates = [v for v in candidates if v["gender"] == gender] candidates = [v for v in candidates if v["gender"] == gender]
@ -90,11 +90,14 @@ class OhFreeMe:
def _call_api(self, text: str, voice_id: int) -> bytes: def _call_api(self, text: str, voice_id: int) -> bytes:
payload = { payload = {
"text": text, "segments": [
"id": voice_id, {
"useEnhance": settings.config["settings"]["tts"].get("ohfreeme_enhance", False), "voiceId": voice_id,
"rate": settings.config["settings"]["tts"].get("ohfreeme_rate", 1), "text": text
"pitch": settings.config["settings"]["tts"].get("ohfreeme_pitch", 0), }
],
"useNaturalVoice": settings.config["settings"]["tts"].get("zall_natural_voice", False),
"enableBrandKeywords": settings.config["settings"]["tts"].get("zall_enable_brand_keywords", False),
} }
headers = { headers = {
"cache-control": "no-cache", "cache-control": "no-cache",
@ -103,52 +106,38 @@ class OhFreeMe:
"accept-language": "en-GB,en-US;q=0.9,en;q=0.8,vi;q=0.7", # important "accept-language": "en-GB,en-US;q=0.9,en;q=0.8,vi;q=0.7", # important
"sec-fetch-mode": "cors", # important "sec-fetch-mode": "cors", # important
"sec-fetch-site": "same-origin", # important "sec-fetch-site": "same-origin", # important
"Cookie": f"auth_token={OHFREEME_JWT_TOKEN}", # important "Cookie": f"auth_token={ZALL_JWT_TOKEN}", # important
"user-agent": self._pick_user_agent(), # important "user-agent": self._pick_user_agent(), # important
# "origin": OHFREEME_BASE_URL,
# "referer": f"{OHFREEME_BASE_URL}/",
} }
# streaming NDJSON response with debug logging
for attempt in range(MAX_RETRIES): for attempt in range(MAX_RETRIES):
resp = requests.post(OHFREEME_API_URL, json=payload, headers=headers, stream=True) resp = requests.post(ZALL_API_URL, json=payload, headers=headers, stream=True)
# Ratelimit handling first line may contain error object audio_bytes = b""
try:
first_line = next(resp.iter_lines())
parsed = json.loads(first_line.decode('utf-8'))
print_substep(f"[OhFreeMe debug] First line parsed: {parsed}", style="blue")
if parsed.get("status") == "error":
print_substep(
f" Rate limited, waiting {RATE_LIMIT_WAIT}s... (attempt {attempt + 1}/{MAX_RETRIES})",
style="yellow",
)
time.sleep(RATE_LIMIT_WAIT)
continue
except (StopIteration, json.JSONDecodeError):
pass
# iterate remaining chunks until done, keeping only the final line for processing
for line in resp.iter_lines(): for line in resp.iter_lines():
if not line: if not line:
continue continue
try: try:
data = json.loads(line.decode('utf-8')) event = json.loads(line.decode("utf-8"))
except json.JSONDecodeError: except json.JSONDecodeError:
continue continue
# debug: print raw line (decoded) to terminal
if data.get("status") == "done": status = event.get("status")
print_substep(f"[OhFreeMe debug] Received") if status == "audio_chunk":
return self._extract_audio(data) audio_bytes += base64.b64decode(event["chunk"])
elif status == "error":
raise RuntimeError(f"OhFreeMe TTS failed after {MAX_RETRIES} retries (rate limited)") print_substep(
f" Rate limited, waiting {RATE_LIMIT_WAIT}s... (attempt {attempt + 1}/{MAX_RETRIES})",
def _extract_audio(self, data: dict) -> bytes: style="yellow",
# Expecting a dict with a "url" field containing a data URI )
url = data.get("url") time.sleep(RATE_LIMIT_WAIT)
if not url: break
raise RuntimeError("Missing 'url' in API response data") elif status == "done":
# url format: "data:audio/mpeg;base64,<base64data>" if not audio_bytes:
if not (url.startswith("data:") and ";base64," in url): raise RuntimeError("Zall TTS completed without audio chunks")
raise RuntimeError(f"Unexpected URL format in API response: {url}") return audio_bytes
b64_part = url.split(";base64,", 1)[1] else:
return base64.b64decode(b64_part) raise RuntimeError("Zall TTS response ended before completion")
raise RuntimeError(f"Zall TTS failed after {MAX_RETRIES} retries (rate limited)")

@ -1,73 +1,67 @@
[ [
{ {
"id": 27, "id": "GFFzHH1GgnlSoBXpmeYS",
"name": "Trần Sơn", "name": "Trần Sơn",
"gender": "male", "gender": "male",
"lang": "vi" "lang": "vi"
}, },
{ {
"id": 962, "id": "emVXmpOD9cWPjuNIV1vb",
"name": "Lê Quốc Khánh", "name": "Tùng Duy",
"gender": "male", "gender": "male",
"lang": "vi" "lang": "vi"
}, },
{ {
"id": 1543, "id": "u1GK69d224tVltnhZaD9",
"name": "Nguyễn Lam Anh", "name": "Minh Quân",
"gender": "male", "gender": "male",
"lang": "vi" "lang": "vi"
}, },
{ {
"id": 510, "id": "Rj3ur2PrLr3JvwYhGAxT",
"name": "Nguyễn Ngân", "name": "Khánh Ly",
"gender": "female", "gender": "female",
"lang": "vi" "lang": "vi"
}, },
{ {
"id": 524, "id": "gl5jjR8ul3WEOIkk7aOc",
"name": "Nguyễn Huyền Trang", "name": "Nguyễn Huyền Trang",
"gender": "female", "gender": "female",
"lang": "vi" "lang": "vi"
}, },
{ {
"id": 1601, "id": "pofi4Uk4l5pDRzr9wxvt",
"name": "Nguyễn Thu Huyền", "name": "Ivy Le",
"gender": "female",
"lang": "vi"
},
{
"id": 713,
"name": "Harper Lee",
"gender": "female", "gender": "female",
"lang": "en" "lang": "en"
}, },
{ {
"id": 551, "id": "zba1eCUoRMYf97gVI3Zd",
"name": "Diana Prince", "name": "Cassie J",
"gender": "female", "gender": "female",
"lang": "en" "lang": "en"
}, },
{ {
"id": 942, "id": "QbFQ0nxCenuuHtiASppf",
"name": "Sophie Blake", "name": "Hope",
"gender": "female", "gender": "female",
"lang": "en" "lang": "en"
}, },
{ {
"id": 597, "id": "P9HN0ybfh8Ny3A6jJH7v",
"name": "Tom Holland", "name": "Archer Kingsley",
"gender": "male", "gender": "male",
"lang": "en" "lang": "en"
}, },
{ {
"id": 1371, "id": "xabWAiYfcCaEBoWjJv3d",
"name": "Jack Sparrow", "name": "Webb",
"gender": "male", "gender": "male",
"lang": "en" "lang": "en"
}, },
{ {
"id": 7, "id": "Q0kRD2oNYVHJoswQ4IEs",
"name": "Patrick O'Cornor", "name": "Patty Wells",
"gender": "male", "gender": "male",
"lang": "en" "lang": "en"
} }

@ -200,7 +200,7 @@ class ManualTTSProcessor:
Reuses the TTS engines from video_creation/voices.py Reuses the TTS engines from video_creation/voices.py
""" """
from TTS.GTTS import GTTS from TTS.GTTS import GTTS
from TTS.OhFreeMe import OhFreeMe from TTS.Zall import Zall
from TTS.Crikk import Crikk from TTS.Crikk import Crikk
from TTS.TikTok import TikTok from TTS.TikTok import TikTok
from TTS.aws_polly import AWSPolly from TTS.aws_polly import AWSPolly
@ -211,7 +211,7 @@ class ManualTTSProcessor:
providers = { providers = {
"googletranslate": GTTS, "googletranslate": GTTS,
"ohfreeme": OhFreeMe, "zall": Zall,
"crikk": Crikk, "crikk": Crikk,
"awspolly": AWSPolly, "awspolly": AWSPolly,
"streamlabspolly": StreamlabsPolly, "streamlabspolly": StreamlabsPolly,

@ -104,7 +104,7 @@ _BASE_SETTINGS_DEFAULTS = {
"background_thumbnail_font_color": "255,255,255", "background_thumbnail_font_color": "255,255,255",
}, },
"tts": { "tts": {
"voice_choice": "ohfreeme", "voice_choice": "zall",
"random_voice": False, "random_voice": False,
"elevenlabs_voice_name": "Bella", "elevenlabs_voice_name": "Bella",
"elevenlabs_api_key": "", "elevenlabs_api_key": "",
@ -120,11 +120,12 @@ _BASE_SETTINGS_DEFAULTS = {
"openai_api_key": "", "openai_api_key": "",
"openai_voice_name": "alloy", "openai_voice_name": "alloy",
"openai_model": "tts-1", "openai_model": "tts-1",
"ohfreeme_lang": "vi", "zall_lang": "vi",
"ohfreeme_gender": "random", "zall_gender": "random",
"ohfreeme_rate": 1, "zall_rate": 1,
"ohfreeme_pitch": 0, "zall_pitch": 0,
"ohfreeme_enhance": False, "zall_natural_voice": True,
"zall_enable_brand_keywords": False,
}, },
}, },
} }

Loading…
Cancel
Save