From 69c160ba77518381dbbbe9b81f320a3672d6cf6e Mon Sep 17 00:00:00 2001 From: PeaceMaker-best Date: Thu, 3 Sep 2026 22:10:55 +0800 Subject: [PATCH] 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> --- skills/public/podcast-generation/SKILL.md | 4 ++ .../podcast-generation/scripts/generate.py | 43 ++++++++----- tests/skills/test_podcast_generation.py | 62 +++++++++++++++++-- 3 files changed, 89 insertions(+), 20 deletions(-) diff --git a/skills/public/podcast-generation/SKILL.md b/skills/public/podcast-generation/SKILL.md index 896a6e936..23f5e0523 100644 --- a/skills/public/podcast-generation/SKILL.md +++ b/skills/public/podcast-generation/SKILL.md @@ -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 diff --git a/skills/public/podcast-generation/scripts/generate.py b/skills/public/podcast-generation/scripts/generate.py index 0e65e9afd..939e649e1 100644 --- a/skills/public/podcast-generation/scripts/generate.py +++ b/skills/public/podcast-generation/scripts/generate.py @@ -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( diff --git a/tests/skills/test_podcast_generation.py b/tests/skills/test_podcast_generation.py index 222a9f51e..6b76601c0 100644 --- a/tests/skills/test_podcast_generation.py +++ b/tests/skills/test_podcast_generation.py @@ -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}