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:
PeaceMaker-best 2026-09-03 22:10:55 +08:00 committed by GitHub
parent 6cbfd0c2f0
commit 69c160ba77
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 89 additions and 20 deletions

View File

@ -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

View File

@ -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(

View File

@ -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}