mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-09 13:39:26 +00:00
fix(podcast): make Volcengine voices configurable (#5156)
Signed-off-by: PeaceMaker-best <221849497+PeaceMaker-best@users.noreply.github.com> Co-authored-by: PeaceMaker-best <221849497+PeaceMaker-best@users.noreply.github.com>
This commit is contained in:
parent
6cbfd0c2f0
commit
69c160ba77
@ -176,6 +176,10 @@ The following environment variables must be set:
|
||||
- For Volcengine: `VOLCENGINE_TTS_APPID` and `VOLCENGINE_TTS_ACCESS_TOKEN`
|
||||
- For MiniMax: `MINIMAX_API_KEY`
|
||||
- `VOLCENGINE_TTS_CLUSTER`: Volcengine TTS cluster (optional, defaults to "volcano_tts")
|
||||
- `VOLCENGINE_TTS_VOICE_TYPE_MALE`: Volcengine male voice type (optional, defaults to `zh_male_yangguangqingnian_moon_bigtts`)
|
||||
- `VOLCENGINE_TTS_VOICE_TYPE_FEMALE`: Volcengine female voice type (optional, defaults to `zh_female_sajiaonvyou_moon_bigtts`)
|
||||
|
||||
Voice type overrides are trimmed; unset or blank values use the listed defaults.
|
||||
|
||||
## Notes
|
||||
|
||||
|
||||
@ -7,7 +7,7 @@ import random
|
||||
import time
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from typing import Literal, Optional
|
||||
from typing import Literal
|
||||
|
||||
import requests
|
||||
|
||||
@ -20,6 +20,8 @@ MINIMAX_RETRYABLE_CODES = {1000, 1001, 1002, 1039}
|
||||
DEFAULT_TTS_MAX_RETRIES = 4
|
||||
DEFAULT_MAX_WORKERS = 4
|
||||
DEFAULT_MINIMAX_MAX_WORKERS = 1
|
||||
DEFAULT_VOLCENGINE_TTS_VOICE_TYPE_MALE = "zh_male_yangguangqingnian_moon_bigtts"
|
||||
DEFAULT_VOLCENGINE_TTS_VOICE_TYPE_FEMALE = "zh_female_sajiaonvyou_moon_bigtts"
|
||||
|
||||
|
||||
class ScriptLine:
|
||||
@ -29,7 +31,7 @@ class ScriptLine:
|
||||
|
||||
|
||||
class Script:
|
||||
def __init__(self, locale: Literal["en", "zh"] = "en", lines: Optional[list[ScriptLine]] = None):
|
||||
def __init__(self, locale: Literal["en", "zh"] = "en", lines: list[ScriptLine] | None = None):
|
||||
self.locale = locale
|
||||
self.lines = lines or []
|
||||
|
||||
@ -87,7 +89,7 @@ def _default_max_workers(provider: str) -> int:
|
||||
return DEFAULT_MAX_WORKERS
|
||||
|
||||
|
||||
def _parse_retry_after(response) -> Optional[float]:
|
||||
def _parse_retry_after(response) -> float | None:
|
||||
"""Return the server-provided Retry-After (seconds), if any."""
|
||||
headers = getattr(response, "headers", None) or {}
|
||||
value = headers.get("Retry-After")
|
||||
@ -97,7 +99,7 @@ def _parse_retry_after(response) -> Optional[float]:
|
||||
return None
|
||||
|
||||
|
||||
def _backoff_sleep(attempt: int, retry_after: Optional[float]) -> None:
|
||||
def _backoff_sleep(attempt: int, retry_after: float | None) -> None:
|
||||
"""Sleep with exponential backoff + jitter, honoring Retry-After when present.
|
||||
|
||||
Jitter de-synchronizes concurrent workers that all got rate-limited at once,
|
||||
@ -108,8 +110,8 @@ def _backoff_sleep(attempt: int, retry_after: Optional[float]) -> None:
|
||||
|
||||
|
||||
def text_to_speech_volcengine(
|
||||
text: str, voice_type: str, max_retries: Optional[int] = None
|
||||
) -> Optional[bytes]:
|
||||
text: str, voice_type: str, max_retries: int | None = None
|
||||
) -> bytes | None:
|
||||
"""Convert text to speech using Volcengine TTS (returns base64-decoded mp3 bytes).
|
||||
|
||||
Retries with exponential backoff on transient HTTP errors (429 / 5xx).
|
||||
@ -161,15 +163,15 @@ def text_to_speech_volcengine(
|
||||
|
||||
|
||||
def text_to_speech_minimax(
|
||||
text: str, voice_id: str, max_retries: Optional[int] = None
|
||||
) -> Optional[bytes]:
|
||||
text: str, voice_id: str, max_retries: int | None = None
|
||||
) -> bytes | None:
|
||||
"""Convert text to speech using MiniMax t2a_v2 (returns hex-decoded mp3 bytes).
|
||||
|
||||
Retries with exponential backoff on HTTP 429/5xx and on retryable base_resp
|
||||
codes (rate/TPM limits, timeouts). Permanent errors (auth, balance, bad input)
|
||||
are not retried.
|
||||
"""
|
||||
api_key = os.getenv("MINIMAX_API_KEY")
|
||||
minimax_api_key = os.getenv("MINIMAX_API_KEY")
|
||||
host = os.getenv("MINIMAX_API_HOST", MINIMAX_DEFAULT_HOST).rstrip("/")
|
||||
if max_retries is None:
|
||||
max_retries = _default_max_retries()
|
||||
@ -184,7 +186,10 @@ def text_to_speech_minimax(
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{host}/v1/t2a_v2",
|
||||
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
||||
headers={
|
||||
"Authorization": f"Bearer {minimax_api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json=payload,
|
||||
timeout=60,
|
||||
)
|
||||
@ -228,7 +233,7 @@ def text_to_speech_minimax(
|
||||
return None
|
||||
|
||||
|
||||
def _process_line(args: tuple[int, ScriptLine, int, str]) -> tuple[int, Optional[bytes]]:
|
||||
def _process_line(args: tuple[int, ScriptLine, int, str]) -> tuple[int, bytes | None]:
|
||||
"""Process a single script line for TTS. Returns (index, audio_bytes)."""
|
||||
i, line, total, provider = args
|
||||
logger.info(f"Processing line {i + 1}/{total} ({line.speaker}) via {provider}")
|
||||
@ -240,9 +245,15 @@ def _process_line(args: tuple[int, ScriptLine, int, str]) -> tuple[int, Optional
|
||||
audio = text_to_speech_minimax(line.paragraph, voice)
|
||||
else:
|
||||
if line.speaker == "male":
|
||||
voice = "zh_male_yangguangqingnian_moon_bigtts"
|
||||
voice = (
|
||||
os.getenv("VOLCENGINE_TTS_VOICE_TYPE_MALE", "").strip()
|
||||
or DEFAULT_VOLCENGINE_TTS_VOICE_TYPE_MALE
|
||||
)
|
||||
else:
|
||||
voice = "zh_female_sajiaonvyou_moon_bigtts"
|
||||
voice = (
|
||||
os.getenv("VOLCENGINE_TTS_VOICE_TYPE_FEMALE", "").strip()
|
||||
or DEFAULT_VOLCENGINE_TTS_VOICE_TYPE_FEMALE
|
||||
)
|
||||
audio = text_to_speech_volcengine(line.paragraph, voice)
|
||||
if not audio:
|
||||
logger.warning(f"Failed to generate audio for line {i + 1}")
|
||||
@ -275,7 +286,7 @@ def tts_node(script: Script) -> list[bytes]:
|
||||
logger.info(f"Converting script to audio using {max_workers} workers (provider={provider})...")
|
||||
tasks = [(i, line, total, provider) for i, line in enumerate(script.lines)]
|
||||
|
||||
results: dict[int, Optional[bytes]] = {}
|
||||
results: dict[int, bytes | None] = {}
|
||||
failed_indices: list[int] = []
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
futures = {executor.submit(_process_line, task): task[0] for task in tasks}
|
||||
@ -318,8 +329,8 @@ def generate_markdown(script: Script, title: str = "Podcast Script") -> str:
|
||||
|
||||
|
||||
def generate_podcast(script_file: str, output_file: str,
|
||||
transcript_file: Optional[str] = None) -> str:
|
||||
with open(script_file, "r", encoding="utf-8") as f:
|
||||
transcript_file: str | None = None) -> str:
|
||||
with open(script_file, encoding="utf-8") as f:
|
||||
script_json = json.load(f)
|
||||
if "lines" not in script_json:
|
||||
raise ValueError(
|
||||
|
||||
@ -11,10 +11,20 @@ pod = load("podcast-generation")
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_env(monkeypatch):
|
||||
for k in ["VOLCENGINE_TTS_APPID", "VOLCENGINE_TTS_ACCESS_TOKEN", "VOLCENGINE_TTS_CLUSTER",
|
||||
"MINIMAX_API_KEY", "PODCAST_GENERATION_PROVIDER", "MINIMAX_API_HOST",
|
||||
"MINIMAX_TTS_MODEL", "MINIMAX_TTS_VOICE_MALE", "MINIMAX_TTS_VOICE_FEMALE",
|
||||
"MINIMAX_TTS_MAX_RETRIES"]:
|
||||
for k in [
|
||||
"VOLCENGINE_TTS_APPID",
|
||||
"VOLCENGINE_TTS_ACCESS_TOKEN",
|
||||
"VOLCENGINE_TTS_CLUSTER",
|
||||
"VOLCENGINE_TTS_VOICE_TYPE_MALE",
|
||||
"VOLCENGINE_TTS_VOICE_TYPE_FEMALE",
|
||||
"MINIMAX_API_KEY",
|
||||
"PODCAST_GENERATION_PROVIDER",
|
||||
"MINIMAX_API_HOST",
|
||||
"MINIMAX_TTS_MODEL",
|
||||
"MINIMAX_TTS_VOICE_MALE",
|
||||
"MINIMAX_TTS_VOICE_FEMALE",
|
||||
"MINIMAX_TTS_MAX_RETRIES",
|
||||
]:
|
||||
monkeypatch.delenv(k, raising=False)
|
||||
# never actually sleep during backoff in tests
|
||||
monkeypatch.setattr(pod.time, "sleep", lambda *_: None)
|
||||
@ -135,6 +145,50 @@ def test_process_line_minimax_male_and_override(monkeypatch):
|
||||
assert seen[-1] == "custom-male"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("speaker", "env_name", "default_voice"),
|
||||
[
|
||||
(
|
||||
"male",
|
||||
"VOLCENGINE_TTS_VOICE_TYPE_MALE",
|
||||
"zh_male_yangguangqingnian_moon_bigtts",
|
||||
),
|
||||
(
|
||||
"female",
|
||||
"VOLCENGINE_TTS_VOICE_TYPE_FEMALE",
|
||||
"zh_female_sajiaonvyou_moon_bigtts",
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("configured_voice", "expected_voice"),
|
||||
[
|
||||
pytest.param(None, None, id="unset"),
|
||||
pytest.param("custom-voice", "custom-voice", id="custom"),
|
||||
pytest.param("", None, id="empty"),
|
||||
pytest.param(" ", None, id="whitespace"),
|
||||
pytest.param(" custom-voice ", "custom-voice", id="padded-custom"),
|
||||
],
|
||||
)
|
||||
def test_process_line_volcengine_voice_mapping(
|
||||
monkeypatch, speaker, env_name, default_voice, configured_voice, expected_voice
|
||||
):
|
||||
if configured_voice is not None:
|
||||
monkeypatch.setenv(env_name, configured_voice)
|
||||
|
||||
seen = {}
|
||||
|
||||
def fake_tts(text, voice_type):
|
||||
seen["voice_type"] = voice_type
|
||||
return b"x"
|
||||
|
||||
monkeypatch.setattr(pod, "text_to_speech_volcengine", fake_tts)
|
||||
line = pod.ScriptLine(speaker=speaker, paragraph="hi")
|
||||
|
||||
pod._process_line((0, line, 1, "volcengine"))
|
||||
assert seen["voice_type"] == (expected_voice or default_voice)
|
||||
|
||||
|
||||
def _seq_post(responses):
|
||||
"""Return a fake requests.post that yields the given responses in order."""
|
||||
calls = {"n": 0}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user