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:
+110
-13
@@ -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())
|
||||
|
||||
+78
-21
@@ -22,7 +22,11 @@ _FORMAT_CHOICES = ["mp3", "wav", "pcm"]
|
||||
|
||||
|
||||
def _get_credentials():
|
||||
"""Get Volcengine credentials from config."""
|
||||
"""Get Volcengine appid + access_token (STT, legacy TTS).
|
||||
|
||||
STT still uses the legacy appid/token app auth; the newer X-Api-Key path is
|
||||
TTS-only and lives in ``_get_tts_credentials``.
|
||||
"""
|
||||
appid = config.resolve_key("secrets.volcengine.app_id", "VOLC_APPID")
|
||||
access_token = config.resolve_key(
|
||||
"secrets.volcengine.access_token", "VOLC_ACCESS_TOKEN"
|
||||
@@ -32,6 +36,23 @@ def _get_credentials():
|
||||
return appid, access_token
|
||||
|
||||
|
||||
def _get_tts_credentials() -> dict:
|
||||
"""TTS credentials from config (new api_key preferred).
|
||||
|
||||
Newer X-Api-Key auth (``secrets.api_keys.volc_tts`` / ``VOLC_TTS_API_KEY``)
|
||||
takes precedence; legacy appid + access_token is the fallback.
|
||||
"""
|
||||
api_key = config.resolve_key(
|
||||
"secrets.api_keys.volc_tts", "VOLC_TTS_API_KEY"
|
||||
)
|
||||
if api_key:
|
||||
return {"api_key": api_key}
|
||||
appid, access_token = _get_credentials()
|
||||
if not appid or not access_token:
|
||||
return {}
|
||||
return {"appid": appid, "token": access_token}
|
||||
|
||||
|
||||
def _synthesize_sync(
|
||||
text: str,
|
||||
speaker: str,
|
||||
@@ -39,18 +60,20 @@ def _synthesize_sync(
|
||||
speech_rate: int,
|
||||
output_path: str,
|
||||
) -> str:
|
||||
"""Synchronous wrapper around async WebSocket TTS."""
|
||||
appid, access_token = _get_credentials()
|
||||
if not appid or not access_token:
|
||||
"""Synchronous wrapper around async TTS."""
|
||||
creds = _get_tts_credentials()
|
||||
if not creds:
|
||||
raise click.UsageError(
|
||||
"Volcengine credentials not set. Run:\n"
|
||||
" mytoolkit env set secrets.volcengine.app_id <appid>\n"
|
||||
"Volcengine TTS credentials not set. Run:\n"
|
||||
" mytoolkit env set secrets.api_keys.volc_tts <api_key>\n"
|
||||
" (or legacy) mytoolkit env set secrets.volcengine.app_id <appid>\n"
|
||||
" mytoolkit env set secrets.volcengine.access_token <token>"
|
||||
)
|
||||
|
||||
return synthesize_tts(
|
||||
appid=appid,
|
||||
token=access_token,
|
||||
appid=creds.get("appid", ""),
|
||||
token=creds.get("token", ""),
|
||||
api_key=creds.get("api_key"),
|
||||
text=text,
|
||||
speaker=speaker,
|
||||
fmt=fmt,
|
||||
@@ -67,7 +90,9 @@ def voice():
|
||||
|
||||
@voice.command("tts")
|
||||
@click.argument("text", required=False)
|
||||
@click.option("-f", "--file", type=click.Path(exists=True), help="从文件读取文本")
|
||||
@click.option(
|
||||
"-f", "--file", type=click.Path(exists=True), help="从文件读取文本"
|
||||
)
|
||||
@click.option(
|
||||
"-v",
|
||||
"--voice",
|
||||
@@ -76,7 +101,13 @@ def voice():
|
||||
default=_DEFAULT_VOICE,
|
||||
help="音色",
|
||||
)
|
||||
@click.option("--format", "fmt", type=click.Choice(_FORMAT_CHOICES), default="mp3", help="输出格式")
|
||||
@click.option(
|
||||
"--format",
|
||||
"fmt",
|
||||
type=click.Choice(_FORMAT_CHOICES),
|
||||
default="mp3",
|
||||
help="输出格式",
|
||||
)
|
||||
@click.option(
|
||||
"--speed",
|
||||
type=click.IntRange(-50, 100),
|
||||
@@ -144,7 +175,9 @@ def stt_cmd(input_path, output, history_file, hotword, correct):
|
||||
if history_file:
|
||||
history = [
|
||||
ln.strip()
|
||||
for ln in Path(history_file).read_text(encoding="utf-8").splitlines()
|
||||
for ln in Path(history_file)
|
||||
.read_text(encoding="utf-8")
|
||||
.splitlines()
|
||||
if ln.strip()
|
||||
]
|
||||
hotwords = list(hotword) if hotword else None
|
||||
@@ -168,7 +201,9 @@ def stt_cmd(input_path, output, history_file, hotword, correct):
|
||||
ark_api_key=ark_key,
|
||||
deepseek_api_key=deepseek_key,
|
||||
ark_model=ark_model,
|
||||
on_skip=lambda msg: click.echo(f"ASR correct skipped: {msg}", err=True),
|
||||
on_skip=lambda msg: click.echo(
|
||||
f"ASR correct skipped: {msg}", err=True
|
||||
),
|
||||
)
|
||||
if output:
|
||||
Path(output).write_text(text + "\n", encoding="utf-8")
|
||||
@@ -179,15 +214,26 @@ def stt_cmd(input_path, output, history_file, hotword, correct):
|
||||
|
||||
def _natural_sort_key(path: Path):
|
||||
name = path.stem
|
||||
return [int(text) if text.isdigit() else text.lower()
|
||||
for text in re.split(r"(\d+)", name)]
|
||||
return [
|
||||
int(text) if text.isdigit() else text.lower()
|
||||
for text in re.split(r"(\d+)", name)
|
||||
]
|
||||
|
||||
|
||||
@voice.command("tts-batch")
|
||||
@click.argument("text_dir", type=click.Path(exists=True, file_okay=False))
|
||||
@click.option("--voice", "speaker", type=click.Choice(_VOICE_CHOICES), default=_DEFAULT_VOICE)
|
||||
@click.option("--output-dir", "-o", type=click.Path(), help="输出目录(默认与输入相同)")
|
||||
@click.option("--format", "fmt", type=click.Choice(_FORMAT_CHOICES), default="mp3")
|
||||
@click.option(
|
||||
"--voice",
|
||||
"speaker",
|
||||
type=click.Choice(_VOICE_CHOICES),
|
||||
default=_DEFAULT_VOICE,
|
||||
)
|
||||
@click.option(
|
||||
"--output-dir", "-o", type=click.Path(), help="输出目录(默认与输入相同)"
|
||||
)
|
||||
@click.option(
|
||||
"--format", "fmt", type=click.Choice(_FORMAT_CHOICES), default="mp3"
|
||||
)
|
||||
@handle_errors
|
||||
def tts_batch_cmd(text_dir, speaker, output_dir, fmt):
|
||||
"""批量将目录下的 .txt 文件转为语音。"""
|
||||
@@ -213,10 +259,17 @@ def tts_batch_cmd(text_dir, speaker, output_dir, fmt):
|
||||
@voice.command("tts-script")
|
||||
@click.argument("script", type=click.Path(exists=True))
|
||||
@click.option("--output-dir", "-o", type=click.Path(), default="audio")
|
||||
@click.option("--voice", "speaker", type=click.Choice(_VOICE_CHOICES), default=_DEFAULT_VOICE)
|
||||
@click.option(
|
||||
"--voice",
|
||||
"speaker",
|
||||
type=click.Choice(_VOICE_CHOICES),
|
||||
default=_DEFAULT_VOICE,
|
||||
)
|
||||
@click.option("--prefix", "-p", default="page")
|
||||
@click.option("--zero-pad", "-z", type=int, default=2)
|
||||
@click.option("--format", "fmt", type=click.Choice(_FORMAT_CHOICES), default="mp3")
|
||||
@click.option(
|
||||
"--format", "fmt", type=click.Choice(_FORMAT_CHOICES), default="mp3"
|
||||
)
|
||||
@handle_errors
|
||||
def tts_script_cmd(script, output_dir, speaker, prefix, zero_pad, fmt):
|
||||
"""从 PPT 配音脚本(Markdown,每页一个引用块)批量生成 TTS。"""
|
||||
@@ -247,8 +300,12 @@ def tts_script_cmd(script, output_dir, speaker, prefix, zero_pad, fmt):
|
||||
continue
|
||||
filename = f"{prefix}{str(i).zfill(zero_pad)}.{fmt}"
|
||||
out_path = output_dir / filename
|
||||
click.echo(f"[{i}] {filename}: {script_text[:40]}{'...' if len(script_text) > 40 else ''}")
|
||||
click.echo(
|
||||
f"[{i}] {filename}: {script_text[:40]}{'...' if len(script_text) > 40 else ''}"
|
||||
)
|
||||
_synthesize_sync(script_text, speaker, fmt, 0, str(out_path)) # type: ignore[reportCallIssue]
|
||||
generated += 1
|
||||
|
||||
click.echo(f"\nDone: {generated} generated, {skipped} skipped (empty scripts).")
|
||||
click.echo(
|
||||
f"\nDone: {generated} generated, {skipped} skipped (empty scripts)."
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user