Skill: volcengine-skills/byted-text-to-speech (version 1.0.0)

--- name: byted-text-to-speech version: 1.0.0 author: volcengine-speech-team description: 将文本合成为语音（TTS）。使用火山引擎豆包语音合成 API，支持流式合成、多种音色、语速/音调/音量调节、Markdown 过滤和 LaTeX 公式播报。当用户需要把文字转成语音、生成朗读音频、配音、旁白、播报，或提到「文字转语音」「TTS」「语音合成」「朗读」「配音」时使用本技能。 license: Complete terms in LICENSE homepage: https://www.volcengine.com/docs/6561/1257543 --- # Byted-Text-to-Speech Skill 基于[火山引擎豆包语音合成](https://www.volcengine.com/docs/6561/1598757)（HTTP Chunked/SSE 单向流式-V3）将文本转为语音并保存为音频文件。 ## 何时使用 当用户有以下需求时，优先使用本 skill： - 需要把一段文字转成语音、朗读音频 - 需要生成配音、旁白、播报、有声读物片段 - 需要将代码注释、文档、文章等内容转为音频便于收听 - 需要生成多语言语音（中文、英文等） - 用户提到「文字转语音」「TTS」「语音合成」「朗读」「配音」「念出来」「读给我听」 - 用户没有明确说"语音合成"，但任务本质上需要将文本内容转为可播放的音频时 ## 使用前检查 优先检查是否已配置以下凭证： - `MODEL_SPEECH_API_KEY` 如果缺少凭证，打开 `references/setup-guide.md` 查看开通、申请和配置方式，并给予用户开通建议 ## 脚本参数 | 参数 | 简写 | 必填 | 说明 | |------|------|------|------| | `--text` | `-t` | 是 | 要合成的文本内容 | | `--output` | `-o` | 否 | 输出音频文件路径（默认自动生成） | | `--speaker` | `-s` | 否 | 发音人，默认 `zh_female_vv_uranus_bigtts`，[音色列表](https://www.volcengine.com/docs/6561/1257544) | | `--format` | | 否 | 音频格式：`mp3`（默认）、`pcm`、`ogg_opus` | | `--sample-rate` | | 否 | 采样率，如 16000、24000（默认 24000） | | `--speech-rate` | | 否 | 语速 [-50, 100]，100 代表 2.0 倍速，-50 代表 0.5 倍速，默认 0 | | `--pitch-rate` | | 否 | 音调 [-12, 12]，默认 0 | | `--loudness-rate` | | 否 | 音量 [-50, 100]，100 代表 2.0 倍音量，-50 代表 0.5 倍音量，默认 0 | | `--bit-rate` | | 否 | 比特率，对 mp3 和 ogg_opus 格式生效（如 64000、128000），默认 64000 | | `--filter-markdown` | | 否 | 过滤 markdown 语法（如 `**你好**` 读为"你好"），默认关闭 | | `--enable-latex` | | 否 | 启用 LaTeX 公式播报（使用 latex\_parser v2，自动开启 markdown 过滤），默认关闭 | ## 返回值说明 脚本输出 JSON，包含： - `status`: `"success"` 或 `"error"` - `local_path`: 本地音频文件路径 - `format`: 音频格式 - `error`: 失败时的错误信息 请将 `local_path` 或可访问的音频 URL 返回给用户，便于播放或下载。 ## 错误处理 - 若报错 `PermissionError: MODEL_SPEECH_API_KEY ... 需在环境变量中配置`：提示用户在 [API Key 管理](https://console.volcengine.com/speech/new/setting/apikeys) 获取并配置 `MODEL_SPEECH_API_KEY`，写入 workspace 下的环境变量文件后重试。 - 若返回 4xx/5xx 或业务错误码：根据错误信息提示用户检查文本内容、发音人 ID 及账号是否已开通豆包语音服务。 ## 故障排查 - 缺少凭证：打开 `references/setup-guide.md` - 需要查 API 参数、字段、错误码：打开 `references/docs-index.md` - 如果脚本返回权限错误，优先检查服务是否已开通、凭证是否有效，给予用户明确的操作指引 ## 参考资料 按需打开以下文件，不必默认全部加载： - `references/setup-guide.md`：服务开通、凭证申请、环境变量配置 - `references/docs-index.md`：API 文档索引、参数说明、音色列表、错误码速查 ## 示例 ```bash # 基本用法 python scripts/text_to_speech.py -t "欢迎使用火山引擎语音合成服务。" # 指定发音人与输出格式 python scripts/text_to_speech.py -t "这是一段测试语音。" -s zh_female_vv_uranus_bigtts -o output.mp3 --format mp3 # 指定语速与采样率 python scripts/text_to_speech.py -t "语速和音调可调。" --speech-rate 10 --sample-rate 16000 ```

path: requirements.txt
content: # Byted-Text-to-Speech skill dependencies
# Ref: https://www.volcengine.com/docs/6561/1598757
httpx>=0.25.0


path: scripts/text_to_speech.py
content: # Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""
Byted-Text-to-Speech (TTS) using Volcengine Doubao Speech API.
Ref: https://www.volcengine.com/docs/6561/1598757 (HTTP Chunked/SSE 单向流式-V3)
鉴权: 新版控制台 API Key 方案 https://console.volcengine.com/speech/new/setting/apikeys
"""

from __future__ import annotations

import argparse
import base64
import json
import os
import sys
import time
import uuid
from pathlib import Path
from typing import Optional

import httpx

_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
if _SCRIPT_DIR not in sys.path:
    sys.path.insert(0, _SCRIPT_DIR)

from api_key import get_speech_api_key

TTS_API_BASE = os.getenv("MODEL_SPEECH_API_BASE", "openspeech.bytedance.com")
TTS_ENDPOINT = f"https://{TTS_API_BASE}/api/v3/tts/unidirectional/sse"
TTS_RESOURCE_ID = os.getenv("MODEL_SPEECH_TTS_RESOURCE_ID", "seed-tts-2.0")
DEFAULT_SPEAKER = "zh_female_vv_uranus_bigtts"
DEFAULT_FORMAT = "mp3"
DEFAULT_SAMPLE_RATE = 24000
VALID_FORMATS = ("mp3", "pcm", "ogg_opus")
VALID_SAMPLE_RATES = (8000, 16000, 22050, 24000, 32000, 44100, 48000)


def _build_headers() -> dict:
    """构建请求头。使用新版控制台 X-Api-Key 鉴权。"""
    headers = {
        "Content-Type": "application/json",
        "X-Api-Resource-Id": TTS_RESOURCE_ID,
        "X-Api-Request-Id": str(uuid.uuid4()),
    }
    [REDACTED_SECRET]).strip()

    if not api_key:
        raise PermissionError(
            "MODEL_SPEECH_API_KEY 需在环境变量中配置。"
            "见 https://console.volcengine.com/speech/new/setting/apikeys"
        )

    headers["X-Api-Key"] = api_key

    return headers


def _build_body(
    text: str,
    speaker: str,
    audio_format: str,
    sample_rate: int,
    speech_rate: int,
    pitch_rate: int,
    loudness_rate: int = 0,
    bit_rate: Optional[int] = None,
    enable_latex: bool = False,
    filter_markdown: bool = False,
) -> dict:
    """构建 V3 SSE 请求 body。"""
    audio_params: dict = {
        "format": audio_format,
        "speech_rate": speech_rate,
        "loudness_rate": loudness_rate,
    }
    if audio_format in ("mp3", "ogg_opus"):
        audio_params["bit_rate"] = bit_rate if bit_rate is not None else 64000
    additions_dict: dict = {
        "post_process": {"pitch": pitch_rate},
        "disable_markdown_filter": True,
        "enable_latex_tn": True,
        "latex_parser": "v2",
    }
    if filter_markdown:
        additions_dict["disable_markdown_filter"] = False
    if not enable_latex:
        additions_dict["enable_latex_tn"] = False
    return {
        "user": {"uid": "skill_tts_user"},
        "req_params": {
            "text": text,
            "speaker": speaker,
            "sample_rate": sample_rate,
            "audio_params": audio_params,
            "additions": json.dumps(additions_dict),
        },
    }


def text_to_speech(
    text: str,
    output_path: Optional[str] = None,
    speaker: str = DEFAULT_SPEAKER,
    audio_format: str = DEFAULT_FORMAT,
    sample_rate: int = DEFAULT_SAMPLE_RATE,
    speech_rate: int = 0,
    pitch_rate: int = 0,
    loudness_rate: int = 0,
    bit_rate: Optional[int] = None,
    enable_latex: bool = False,
    filter_markdown: bool = False,
    timeout: float = 60.0,
) -> dict:
    """
    调用火山引擎豆包语音合成 API，将文本转为语音并保存为文件。

    Returns:
        包含 status, local_path, format, error 的字典
    """
    if not text or not text.strip():
        return {
            "status": "error",
            "local_path": None,
            "format": audio_format,
            "error": "text is empty",
        }
    if audio_format not in VALID_FORMATS:
        return {
            "status": "error",
            "local_path": None,
            "format": audio_format,
            "error": f"format must be one of {VALID_FORMATS}",
        }
    if sample_rate not in VALID_SAMPLE_RATES:
        return {
            "status": "error",
            "local_path": None,
            "format": audio_format,
            "error": f"sample_rate must be one of {VALID_SAMPLE_RATES}",
        }

    if output_path is None:
        ts = int(time.time())
        output_path = f"tts_output_{ts}.{audio_format}"

    output_path = os.path.abspath(output_path)
    Path(output_path).parent.mkdir(parents=True, exist_ok=True)

    headers = _build_headers()
    body = _build_body(
        text=text.strip(),
        speaker=speaker,
        audio_format=audio_format,
        sample_rate=sample_rate,
        speech_rate=speech_rate,
        pitch_rate=pitch_rate,
        loudness_rate=loudness_rate,
        bit_rate=bit_rate,
        enable_latex=enable_latex,
        filter_markdown=filter_markdown,
    )

    try:
        with httpx.Client(timeout=timeout) as client:
            with client.stream("POST", TTS_ENDPOINT, headers=headers, json=body) as resp:
                resp.raise_for_status()

                audio_chunks = []
                for line in resp.iter_lines():
                    if not line.startswith("data:"):
                        continue
                    try:
                        d = json.loads(line[5:].strip())
                        code = d.get("code", 0)
                        if code not in (0, 20000000):
                            return {
                                "status": "error",
                                "local_path": None,
                                "format": audio_format,
                                "error": f"API error code={code}: {d.get('message', '')}",
                            }
                        if d.get("data"):
                            audio_chunks.append(base64.b64decode(d["data"]))
                    except (json.JSONDecodeError, KeyError):
                        continue

        if not audio_chunks:
            return {
                "status": "error",
                "local_path": None,
                "format": audio_format,
                "error": "No audio data returned.",
            }

        with open(output_path, "wb") as f:
            for chunk in audio_chunks:
                f.write(chunk)

        return {
            "status": "success",
            "local_path": output_path,
            "format": audio_format,
            "error": None,
        }
    except Exception as e:
        try:
            if os.path.exists(output_path):
                os.remove(output_path)
        except OSError:
            pass
        return {
            "status": "error",
            "local_path": None,
            "format": audio_format,
            "error": str(e),
        }


def main():
    parser = argparse.ArgumentParser(
        description="Byted-Text-to-Speech using Volcengine Doubao Speech (TTS)"
    )
    parser.add_argument(
        "--text", "-t", required=True, help="Text to synthesize"
    )
    parser.add_argument(
        "--output", "-o", default=None, help="Output audio file path"
    )
    parser.add_argument(
        "--speaker", "-s", default=DEFAULT_SPEAKER, help="Speaker ID"
    )
    parser.add_argument(
        "--format", "-f", choices=VALID_FORMATS, default=DEFAULT_FORMAT,
        help="Audio format",
    )
    parser.add_argument(
        "--sample-rate", type=int, default=DEFAULT_SAMPLE_RATE,
        choices=VALID_SAMPLE_RATES, help="Sample rate",
    )
    parser.add_argument(
        "--speech-rate", type=int, default=0,
        help="Speech rate [-50, 100]",
    )
    parser.add_argument(
        "--pitch-rate", type=int, default=0,
        help="Pitch rate [-12, 12]",
    )
    parser.add_argument(
        "--loudness-rate", type=int, default=0,
        help="Loudness rate [-50, 100]. 100=2x volume, -50=0.5x volume",
    )
    parser.add_argument(
        "--bit-rate", type=int, default=None,
        help="Bit rate (e.g. 64000, 128000). Applies to mp3 and ogg_opus formats, default 64000.",
    )
    parser.add_argument(
        "--enable-latex", action="store_true", default=False,
        help="Enable LaTeX formula reading (uses latex_parser v2)",
    )
    parser.add_argument(
        "--filter-markdown", action="store_true", default=False,
        help="Filter markdown syntax (e.g. **hello** reads as 'hello')",
    )
    parser.add_argument(
        "--timeout", type=float, default=60.0, help="Request timeout (seconds)",
    )
    args = parser.parse_args()

    try:
        result = text_to_speech(
            text=args.text,
            output_path=args.output,
            speaker=args.speaker,
            audio_format=args.format,
            sample_rate=args.sample_rate,
            speech_rate=args.speech_rate,
            pitch_rate=args.pitch_rate,
            loudness_rate=args.loudness_rate,
            bit_rate=args.bit_rate,
            enable_latex=args.enable_latex,
            filter_markdown=args.filter_markdown,
            timeout=args.timeout,
        )
    except PermissionError as e:
        print(json.dumps({
            "status": "error",
            "local_path": None,
            "format": getattr(args, "format", DEFAULT_FORMAT),
            "error": str(e),
        }, indent=2, ensure_ascii=False))
        sys.exit(1)

    print(json.dumps(result, indent=2, ensure_ascii=False))
    sys.exit(0 if result.get("status") == "success" else 1)


if __name__ == "__main__":
    main()


path: scripts/api_key.py
content: import json
import os
import urllib.error
import urllib.request
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse


def _build_action_url(base: str, action: str, version: str = "2025-05-20") -> str:
    base = (base or "").strip()
    if not base:
        return ""
    parsed = urlparse(base)
    path = parsed.path or "/"
    if not path.endswith("/"):
        path = f"{path}/"
    query = dict(parse_qsl(parsed.query))
    query["Action"] = action
    query["Version"] = version
    return urlunparse(parsed._replace(path=path, query=urlencode(query)))


def _extract_json(text: str) -> dict | None:
    if not text:
        return None
    text = text.strip()
    try:
        return json.loads(text)
    except Exception:
        pass
    start = text.find("{")
    end = text.rfind("}")
    if start == -1 or end == -1 or end <= start:
        return None
    try:
        return json.loads(text[start : end + 1])
    except Exception:
        return None


def _post_json(url: str, headers: dict, payload: dict, timeout_s: int = 15) -> dict | None:
    if not url:
        return None
    data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
    req = urllib.request.Request(url, data=data, method="POST")
    for k, v in (headers or {}).items():
        if v is None:
            continue
        req.add_header(k, v)
    try:
        with urllib.request.urlopen(req, timeout=timeout_s) as resp:
            body = resp.read().decode("utf-8", errors="replace")
            return _extract_json(body)
    except urllib.error.HTTPError as e:
        try:
            body = e.read().decode("utf-8", errors="replace")
            return _extract_json(body)
        except Exception:
            return None
    except Exception:
        return None


def _dotenv_path() -> str:
    return os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env")


def _load_dotenv_if_available(dotenv_path: str) -> None:
    try:
        from dotenv import load_dotenv  # type: ignore
    except Exception:
        return
    try:
        load_dotenv(dotenv_path=dotenv_path, override=False)
    except Exception:
        return


def _persist_env_to_dotenv_if_available(dotenv_path: str, key: str, value: str) -> None:
    value = (value or "").strip()
    if not value:
        return
    try:
        from dotenv import set_key  # type: ignore
    except Exception:
        return
    try:
        os.makedirs(os.path.dirname(dotenv_path) or ".", exist_ok=True)
        set_key(dotenv_path, key, value, quote_mode="never")
        try:
            os.chmod(dotenv_path, 0o600)
        except Exception:
            pass
    except Exception:
        return


def get_speech_api_key(project_name: str = "default", create_name: str = "arkclaw") -> str:
    _load_dotenv_if_available(_dotenv_path())

    speech_key = os.getenv("MODEL_SPEECH_API_KEY", "").strip()
    if speech_key:
        return speech_key

    ark_key = os.getenv("ARK_SKILL_API_KEY", "").strip()
    base = os.getenv("ARK_SKILL_API_BASE", "").strip()
    if not ark_key or not base:
        return ""

    headers = {
        "ServiceName": "speech_saas_prod",
        "Authorization": f"Bearer {ark_key}",
        "Content-Type": "application/json",
    }

    list_url = _build_action_url(base, "ListAPIKeys")
    list_data = _post_json(
        list_url,
        headers=headers,
        payload={"ProjectName": project_name, "OnlyAvailable": True},
    )
    if isinstance(list_data, dict):
        api_keys = (list_data.get("Result") or {}).get("APIKeys") or []
        if isinstance(api_keys, list) and api_keys:
            first = api_keys[0] if isinstance(api_keys[0], dict) else {}
            first_key = (first.get("APIKey") or "").strip()
            if first_key:
                os.environ["MODEL_SPEECH_API_KEY"] = first_key
                _persist_env_to_dotenv_if_available(_dotenv_path(), "MODEL_SPEECH_API_KEY", first_key)
                return first_key

    create_url = _build_action_url(base, "CreateAPIKey")
    create_data = _post_json(
        create_url,
        headers=headers,
        payload={"ProjectName": project_name, "Name": create_name},
    )
    if isinstance(create_data, dict):
        created_key = ((create_data.get("Result") or {}).get("APIKey") or "").strip()
        if created_key:
            os.environ["MODEL_SPEECH_API_KEY"] = created_key
            _persist_env_to_dotenv_if_available(_dotenv_path(), "MODEL_SPEECH_API_KEY", created_key)
            return created_key

    return ""


path: references/docs-index.md
content: # Byted-Text-to-Speech 文档索引

以下资料主要对应火山引擎豆包语音合成 API，是本 skill 依赖的底层文档。

所有文档均位于火山引擎官网。由于官网页面为动态渲染，部分内容无法静态抓取，建议直接在浏览器中访问。

## 核心文档

| 文档 | 链接 |
|------|------|
| **HTTP Chunked/SSE 单向流式-V3**（本 skill 对应的接口） | https://www.volcengine.com/docs/6561/1598757 |
| 产品简介 | https://www.volcengine.com/docs/6561/1257543 |
| 快速入门（新版控制台） | https://www.volcengine.com/docs/6561/2119699 |

## 音色与发音人

| 文档 | 链接 |
|------|------|
| 音色列表 | https://www.volcengine.com/docs/6561/1257544 |
| SSML 标记语言（高级用法） | https://www.volcengine.com/docs/6561/1330194 |

## 鉴权

| 文档 | 链接 |
|------|------|
| API Key 管理 | https://console.volcengine.com/speech/new/setting/apikeys |
| API Key 使用说明 | https://www.volcengine.com/docs/6561/1816214 |

## 控制台入口

| 入口 | 链接 |
|------|------|
| 豆包语音控制台（API Key 管理） | https://console.volcengine.com/speech/new/setting/apikeys |

## API 关键参数速查

### 请求

```
POST https://openspeech.bytedance.com/api/v3/tts/unidirectional/sse
Content-Type: application/json
X-[REDACTED_SECRET]
X-Api-Resource-Id: seed-tts-2.0
```

### 请求体字段

| 字段 | 类型 | 必须 | 说明 |
|------|------|------|------|
| user.uid | String | 是 | 用户标识 |
| req_params.text | String | 是 | 要合成的文本 |
| req_params.speaker | String | 是 | 发音人 ID |
| req_params.sample_rate | Integer | 否 | 采样率，默认 24000 |
| req_params.audio_params.format | String | 否 | 音频格式：mp3、pcm、ogg_opus |
| req_params.audio_params.speech_rate | Integer | 否 | 语速 [-50, 100] |
| req_params.audio_params.loudness_rate | Integer | 否 | 音量 [-50, 100] |
| req_params.audio_params.bit_rate | Integer | 否 | 比特率（mp3/ogg_opus），默认 64000 |
| req_params.additions | String (JSON) | 否 | 扩展参数（JSON 字符串），内部字段见下表 |

#### req_params.additions 内部字段

`additions` 的值为 JSON 字符串，序列化后的内部字段如下：

| 字段 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| post_process.pitch | Integer | 0 | 音调 [-12, 12] |
| disable_markdown_filter | Boolean | true | 是否禁用 markdown 过滤。`true` 保留原始 markdown 语法，`false` 过滤掉 markdown 语法（如 `**你好**` 读为"你好"） |
| enable_latex_tn | Boolean | true | 是否启用 LaTeX 公式播报 |
| latex_parser | String | "v2" | LaTeX 解析器版本 |

示例：

```json
{
  "post_process": {"pitch": 0},
  "disable_markdown_filter": true,
  "enable_latex_tn": true,
  "latex_parser": "v2"
}
```

### 响应（SSE data 行）

| 字段 | 说明 |
|------|------|
| code | 0 或 20000000 表示成功 |
| message | 错误信息 |
| data | Base64 编码的音频数据块 |

### 常见错误码

| 错误码 | 含义 |
|--------|------|
| 401 | API Key 无效或未配置 |
| 403 | 服务未开通或无权限 |
| 429 | 请求频率超限 |
| 500 | 服务端内部错误 |


path: references/setup-guide.md
content: # Byted-Text-to-Speech — 服务开通与凭证申请指南

本文档帮助从零开始申请和配置 Byted-Text-to-Speech 依赖的火山引擎豆包语音合成 API 服务。

## 概览

```
注册账号 → 开通服务 → 获取 API Key → 配置环境变量 → 调用 API
  (1次)      (1次)        (1次)          (1次)         (随时)
```

整个流程约 5~10 分钟。

---

## 第一步：注册火山引擎账号

> 已有账号可跳过。

1. 访问 https://www.volcengine.com
2. 点击右上角【注册】
3. 使用手机号注册（也支持飞书、抖音等第三方账号）
4. 首次登录控制台需完成**实名认证**

---

## 第二步：开通豆包语音合成服务

1. 访问 [豆包语音-产品简介](https://www.volcengine.com/docs/6561/1257543) 了解产品能力
2. 进入 [豆包语音控制台](https://console.volcengine.com/speech/new/setting/apikeys)
3. 按页面引导完成服务开通

---

## 第三步：获取 API Key

1. 打开 [API Key 管理页](https://console.volcengine.com/speech/new/setting/apikeys)
2. 点击【创建 API Key】
3. 复制生成的 API Key

---

## 第四步：配置环境变量

将获取的凭证写入 workspace 根目录下的 `.env` 文件：

```bash
# 必填
MODEL_SPEECH_API_KEY=你的API_Key

# 可选（一般无需修改）
# MODEL_SPEECH_API_BASE=openspeech.bytedance.com
# MODEL_SPEECH_TTS_RESOURCE_ID=seed-tts-2.0
```

---

## 验证配置

```bash
cd skills/byted-text-to-speech
python3 scripts/text_to_speech.py -t "你好，语音合成测试。"
```

如果返回 `"status": "success"` 并生成音频文件，说明配置成功。

---

## 常见问题

| 问题 | 解决方案 |
|------|---------|
| `PermissionError: MODEL_SPEECH_API_KEY 需在环境变量中配置` | 检查 `.env` 文件中是否配置了 `MODEL_SPEECH_API_KEY` |
| API 返回 401/403 | 检查 API Key 是否正确、服务是否已开通 |
| API 返回 429 | 请求频率超限，稍后重试 |


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)