feat(tts): support Volcengine X-Api-Key auth (new api-key mode)

Newer Volcengine speech synthesis auths with a plain API key (secrets.
api_keys.volc_tts / VOLC_TTS_API_KEY) via the HTTP endpoint and X-Api-Key
header — no AppID/Token, and a quota pool that is separate from the legacy
app. synthesize_tts now prefers api_key (HTTP) and falls back to the legacy
WebSocket appid/access_token path. STT still uses appid/token (unchanged).
This commit is contained in:
Zhengshou Lai
2026-08-02 16:08:13 +08:00
parent ee85ed822a
commit 17e6abd035
2 changed files with 188 additions and 34 deletions
+110 -13
View File
@@ -9,18 +9,14 @@ from pathlib import Path
_RESOURCE_TTS_1 = "volc.service_type.10029"
_RESOURCE_TTS_2 = "seed-tts-2.0"
_ENDPOINT = "wss://openspeech.bytedance.com/api/v3/tts/unidirectional/stream"
# Newer HTTP endpoint: X-Api-Key auth (no AppID/Token), streamed base64.
# Note: the legacy appid/token path is WebSocket (_ENDPOINT); the api-key path
# is this HTTP endpoint (the /plan/ variant is Agent-Plan-only and rejects it).
_ENDPOINT_HTTP = "https://openspeech.bytedance.com/api/v3/tts/unidirectional"
# Product labels: 经典男声 / 经典女声 / 阳光甜妹
_VOICE_CHOICES = [
"zh_male_m191_uranus_bigtts",
"zh_female_xiaohe_uranus_bigtts",
"ICL_uranus_zh_female_yuanqitianmei_tob",
]
_DEFAULT_VOICE = "zh_female_xiaohe_uranus_bigtts"
_FORMAT_CHOICES = ["mp3", "wav", "pcm"]
def resource_id_for_speaker(speaker: str) -> str:
"""Pick Volcengine resource id for a speaker."""
@@ -38,18 +34,38 @@ def synthesize_tts(
fmt: str = "mp3",
speech_rate: int = 0,
output_path: str = "output.mp3",
api_key: str | None = None,
) -> str:
"""Synchronous TTS synthesis via Volcengine WebSocket API.
"""Synchronous TTS synthesis via Volcengine API.
Returns absolute path to the output audio file.
Prefers the newer plan HTTP endpoint (``api_key``, X-Api-Key auth); falls
back to the legacy WebSocket endpoint (appid + access_token). Returns the
absolute path to the output audio file.
"""
if api_key:
return synthesize_tts_http(
api_key,
text=text,
speaker=speaker,
fmt=fmt,
speech_rate=speech_rate,
output_path=output_path,
)
# Delayed import to avoid circular dependency when bin.commands is not yet loaded
from mytoolkit.protocols import EventType, MsgType, full_client_request, receive_message
from mytoolkit.protocols import (
EventType,
MsgType,
full_client_request,
receive_message,
)
try:
import websockets
except ImportError as e:
raise RuntimeError(f"Missing dependency: {e}. Run: pip install websockets") from e
raise RuntimeError(
f"Missing dependency: {e}. Run: pip install websockets"
) from e
headers = {
"X-Api-App-Id": appid,
@@ -84,7 +100,10 @@ def synthesize_tts(
audio_data = bytearray()
while True:
msg = await receive_message(ws)
if msg.type == MsgType.FullServerResponse and msg.event == EventType.SessionFinished:
if (
msg.type == MsgType.FullServerResponse
and msg.event == EventType.SessionFinished
):
break
elif msg.type == MsgType.AudioOnlyServer:
audio_data.extend(msg.payload)
@@ -104,3 +123,81 @@ def synthesize_tts(
out = Path(output_path)
out.write_bytes(audio_data)
return str(out.resolve())
def synthesize_tts_http(
api_key: str,
text: str,
*,
speaker: str = _DEFAULT_VOICE,
fmt: str = "mp3",
speech_rate: int = 0,
output_path: str = "output.mp3",
timeout_sec: float = 60.0,
) -> str:
"""TTS via Volcengine v3 plan HTTP endpoint (X-Api-Key auth).
One POST, streamed newline-delimited JSON; each line carries a base64
audio chunk plus a terminal ``code == 20000000`` line. No AppID/Token.
"""
import base64
import json
import urllib.error
import urllib.request
payload = {
"req_params": {
"text": text,
"speaker": speaker,
"audio_params": {
"format": fmt,
"sample_rate": 24000,
"speech_rate": speech_rate,
},
}
}
req = urllib.request.Request(
_ENDPOINT_HTTP,
data=json.dumps(payload).encode("utf-8"),
method="POST",
)
req.add_header("X-Api-Key", api_key)
req.add_header("X-Api-Resource-Id", _RESOURCE_TTS_2)
req.add_header("Content-Type", "application/json")
req.add_header("Connection", "keep-alive")
req.add_header("X-Control-Require-Usage-Tokens-Return", "*")
try:
resp = urllib.request.urlopen(req, timeout=timeout_sec)
except urllib.error.HTTPError as exc:
detail = exc.read()[:300].decode("utf-8", errors="replace")
raise RuntimeError(
f"TTS service error: HTTP {exc.code} {detail}"
) from exc
except OSError as exc:
raise RuntimeError(f"TTS service error: {exc}") from exc
audio_data = bytearray()
for raw in resp:
line = raw.decode("utf-8", errors="replace").strip()
if not line:
continue
try:
obj = json.loads(line)
except json.JSONDecodeError:
continue
code = obj.get("code", 0)
if obj.get("data"):
audio_data.extend(base64.b64decode(obj["data"]))
if code == 20000000: # terminal success
break
if code > 0:
raise RuntimeError(
f"TTS service error: {json.dumps(obj, ensure_ascii=False)[:300]}"
)
if not audio_data:
raise RuntimeError("No audio data received from TTS service")
out = Path(output_path)
out.write_bytes(audio_data)
return str(out.resolve())