Skill: iamzn1018/chanjing-tts (version 1.0.2)

--- name: chanjing-tts description: "Use Chanjing TTS API to convert text to speech. Primary credential: credentials.json (app_id/secret_key; access_token persisted). Not OpenClaw primaryEnv. CHANJING_API_BASE and CHANJING_CONFIG_DIR optional (default API base if unset). No ffmpeg/ffprobe required by this skill's scripts." author: chan-skills binaries: [] env: - CHANJING_CONFIG_DIR - CHANJING_API_BASE category: 媒体处理 tags: - TTS - 语音合成 - ChanjingAPI - 蝉镜 metadata: openclaw: homepage: https://doc.chanjing.cc --- # Chanjing TTS 技能包标识：`chanjing-tts`。 ## 功能说明 调用蝉镜 **TTS** Open API：列举音色、创建合成任务、轮询并从接口返回 URL 下载音频。脚本**不**依赖 ffmpeg/ffprobe。 ## 运行依赖 - **python3** 与同仓库 `scripts/*.py` - **无** ffmpeg/ffprobe 门控 ## 环境变量 1. **CHANJING_CONFIG_DIR**（可选）：`credentials.json` 所在目录，默认 `~/.chanjing` 2. **CHANJING_API_BASE**（可选）：Open API 基址，默认 `https://open-api.chanjing.cc` ## 使用命令 - **ClawHub**（slug 以注册表为准）：`clawhub run chanjing-tts` - **本仓库**：`python skills/chanjing-tts/scripts/create_task.py …`（见正文 **How to Use**） --- ## 登记摘要（英文 · ClawHub / OpenClaw） **Primary credential (not `primaryEnv`)**: `app_id` + `secret_key` in `~/.chanjing/credentials.json` (or `$CHANJING_CONFIG_DIR/credentials.json`); `access_token` **read/written** in the same file. **`primaryEnv` omitted** by design. **Required vs optional**: **`CHANJING_API_BASE`** **optional** (default `https://open-api.chanjing.cc`). **`CHANJING_CONFIG_DIR`** optional. **No** `ffmpeg`/`ffprobe` in skill `metadata`. **Purpose alignment**: **TTS** client—list voices, create task, poll, **download audio from URLs in API responses**. **Trust** the API host and returned URLs. See **How to Use** → **Reviewer Q&A (four items)** → **Security & credentials (registry summary)**. ## When to Use This Skill Use this skill when the user needs to generate audio from text. Chanjing TTS supports: * both Chinese and English * multiple system voices * adjustment of speech speed * sentence-level timestamp in result ## How to Use This Skill **前置条件（权限验证）**：执行本 Skill 前，必须先通过 **chanjing-credentials-guard** 完成 AK/SK 与 Token 校验。本 Skill 与 guard 使用同一套凭证（`~/.chanjing/credentials.json`）；脚本在无凭证时会**执行 `open_login_page.py` 脚本**，在默认浏览器打开 AK/SK 注册/登录页，并提示配置命令。 ### Reviewer Q&A (four items) Direct answers for **Purpose & Capability / Instruction Scope / Credentials / Persistence**-style reviews; aligned with code, top **Registry summary (English)**, and `description` frontmatter. | # | Topic | Answer | |---|-------|--------| | **1** | **Purpose vs implementation; primary credential in registry text** | **Aligned**. This skill is a **Chanjing TTS** API client (list voices, create speech task, poll, fetch audio from **URLs in responses**). **`CHANJING_API_BASE` is not required** (default base URL). **Primary credential** is **`credentials.json`** (`app_id` / `secret_key`, refreshed **`access_token`**); stated in **`description`**, top English summary, and **Security & credentials** below. **`primaryEnv` omitted** (file-based dual keys). **No** `ffmpeg`/`ffprobe` in `metadata`. | | **2** | **Runtime scope: secrets, arbitrary URLs** | **In scope**. Reads/writes **`CHANJING_CONFIG_DIR/credentials.json`**; may open **browser** / **`open_login_page.py`**; **HTTPS** to Open API; **downloads audio using URLs returned by the API**—**you must trust** the API host and those links. | | **3** | **Env vars vs file-stored secrets** | **`CHANJING_CONFIG_DIR` / `CHANJING_API_BASE`** are optional. **AK/SK and token persist on disk**—sensitive; do not commit secrets; avoid echoing full keys in chat. | | **4** | **Persistence & privilege** | Default **`always: false`**; **does not** modify other skills or global agent config. | ### Security & credentials (registry summary) Aligned with **Reviewer Q&A** above, top English registry summary, and `description`. | Aspect | Details | |--------|---------| | **Primary credential** | Chanjing `app_id` and `secret_key` in `~/.chanjing/credentials.json` (override directory with `CHANJING_CONFIG_DIR`). Configured via **chanjing-credentials-guard**. **Not** `primaryEnv` (not a single env-var API key). | | **Token** | Scripts refresh and persist `access_token` / `expire_in` in the same JSON file. | | **Env** | Optional `CHANJING_API_BASE`, `CHANJING_CONFIG_DIR`. | | **Network / browser** | HTTPS to `https://open-api.chanjing.cc`; may open a login page when credentials are missing. | | **Downloads** | After polling success, audio is fetched from URLs in the API response—trust the API host. | | **Persistence** | Default `always: false`; does not modify other skills. | Multiple APIs need to be invoked. All share the domain: "https://open-api.chanjing.cc". All requests communicate using json. You should use utf-8 to encode and decode text throughout this task. 1. Obtain an `access_token`, which is required for all subsequent API calls 2. List all voice IDs and select one to use 3. Call the Create Speech API, record `task_id` 4. Poll the Query Speech Status API until success, then download generated audio file using the url in response ### Obtain AccessToken 从 `~/.chanjing/credentials.json` 读取 `app_id` 和 `secret_key`，若无有效 Token 则调用： ```http POST /open/v1/access_token Content-Type: application/json ``` 请求体（使用本地配置的 app_id、secret_key）： ```json { "app_id": "<从 credentials.json 读取>", "secret_key": "<从 credentials.json 读取>" } ``` Response example: ```json { "trace_id": [REDACTED_SECRET], "code": 0, "msg": "success", "data": { "access_token": [REDACTED_SECRET], "expire_in": 1721289220 } } ``` Response field description: | First-level Field | Second-level Field | Description | |---|---|---| | code | | Response status code | | msg | | Response message | | data | | Response data | | | access\_token | Valid for one day, previous token will be invalidated | | | expire\_in | Token expiration time | Response Status Code Description | Code | Description | |---|---| | 0 | Success | | 400 | Invalid parameter format | | 40000 | Parameter error | | 50000 | System internal error | ### Select a Voice ID Obtain all available voice IDs via API, and select one that fits the task at hand. The dialect/accent can be deduced from the voice name. ```http GET /open/v1/list_common_audio access_token: {{access_token}} ``` Use the following request body: ```json { "page": 1, "size": 100 } Response example: ```json { "trace_id": [REDACTED_SECRET], "code": 0, "msg": "success", "data": { "list": [ { "id": [REDACTED_SECRET], "grade": 0, "name": "带货小芸", "gender": "female", "lang": "multilingual", "desc": "", "speed": 1, "pitch": 1, "audition": "https://res.chanjing.cc/chanjing/res/upload/ms/2025-06-05/7945e0474b8cb526e884ee7e28e4af8d.wav" }, { "id": [REDACTED_SECRET], "grade": 0, "name": "方言口音老奶奶", "gender": "female", "lang": "multilingual", "desc": "", "speed": 1, "pitch": 1, "audition": "https://res.chanjing.cc/chanjing/res/upload/ms/2025-04-30/1b248ad05953028db5a6bcba9a951164.wav" }, ... ], "page_info": { "page": 1, "size": 100, "total_count": 98, "total_page": 1 } } } ``` Response field description: | First-level Field | Second-level Field | Third-level Field | Description | |---|---|---|---| | code | | | Response status code | | message | | | Response message | | data | | | Response data | | | list | List data | Public voice - list data | | | | id | Voice ID | | | | name | Voice name, if it includes a place name, the generated speech is in dialect | | | | gender | Gender | | | | lang | Language | | | | desc | Description | | | | speed | Speech speed | | | | pitch | Pitch | | | | audition | Audition link | | | | grade | Grade | Response status code description: | Code | Description | |---|---| | 0 | Response successful | | 10400 | AccessToken verification failed | | 40000 | Parameter error | | 50000 | System internal error | | 51000 | System internal error | ### Create Speech API Submit a speech creating task, which returns a task ID for polling later. ```http POST /open/v1/create_audio_task access_token: {{access_token}} Content-Type: application/json ``` Request body example: ```json { "audio_man": "89843d52ccd04e2d854decd28d6143ce ", "speed": 1, "pitch": 1, "text": { "text": "Hello, I am your AI assistant." } } ``` Request field description: | Parameter Name | Type | Nested Key | Required | Example | Description | |---|---|---|---|---|---| | audio\_man | string | | Yes | 89843d52ccd04e2d854decd28d6143ce | Voice ID | | speed | number | | Yes | 1 | Speech speed: 0.5 (slow) - 2 (fast) | | pitch | number | | Yes | 1 | Just set to 1 | | text | object | text | Yes | Hello, I am your AI assistant. | Rich text, length must be less than 4000 characters | | aigc\_watermark | bool | | No | false | Whether to add visible watermark to audio, default to false | Response example: ```json { "trace_id": [REDACTED_SECRET], "code": 0, "msg": "success", "data": { "task_id": [REDACTED_SECRET] } } ``` Response field description: | Field | Description | |---|---| | code | Response status code | | msg | Response message | | task\_id | Task ID, to be used in subsequent polling step | Response status code description: | code | Description | | --- | --- | | 0 | Response successful | | 400 | Invalid parameter format | | 10400 | AccessToken verification failed | | 40000 | Parameter error | | 40001 | Exceeds QPS limit | | 40002 | Production duration reached limit | | 50000 | System internal error | #### Poll Query Speech Status API Poll the following API until speech is generated. ```http POST /open/v1/audio_task_state access_token: {{access_token}} Content-Type: application/json ``` Request example: ```json { "task_id": [REDACTED_SECRET] } ``` Request field description: | Parameter Name | Type | Required | Example | Description | |---|---|---|---|---| | task\_id | string | Yes | 88f789dd9b8e4a121abb9d4679e0edc8 | Speech synthesis task ID | Response example: ```json { "trace_id": [REDACTED_SECRET], "code": 0, "msg": "success", "data": { "id": [REDACTED_SECRET], "type": "tts", "status": 9, "text": [ "猫在跌落时能够在空中调整身体，通常能够四脚着地，这种”猫右自己“反射显示了它们惊人的身体协调能力和灵活性。核磁共振成像技术通过利用人体细胞中氢原子的磁性来生成详细的内部图像，为医学诊断提供了重要工具。" ], "full": { "url": "https://cy-cds-test-innovation.cds8.cn/chanjing/res/upload/tts/2025-04-08/093a59021d85a72d28a491f21820ece4.wav", "path": "093a59013d85a72d28a491f21820ece4.wav", "duration": 18.81 }, "slice": null, "errMsg": "", "errReason": "", "subtitles": [ { "key": [REDACTED_SECRET], "start_time": 0, "end_time": 2.77, "subtitle": "猫在跌落时能够在空中调整身体" }, { "key": [REDACTED_SECRET], "start_time": 2.77, "end_time": 4.49, "subtitle": "通常能够四脚着地" }, { "key": [REDACTED_SECRET], "start_time": 4.49, "end_time": 5.73, "subtitle": "这种，猫右自己" }, { "key": [REDACTED_SECRET], "start_time": 5.73, "end_time": 7.97, "subtitle": "反射显示了它们惊人的身体" }, { "key": [REDACTED_SECRET], "start_time": 7.97, "end_time": 9.45, "subtitle": "协调能力和灵活性" }, { "key": [REDACTED_SECRET], "start_time": 9.45, "end_time": 12.49, "subtitle": "核磁共振成像技术通过利用人体" }, { "key": [REDACTED_SECRET], "start_time": 12.49, "end_time": 14.77, "subtitle": "细胞中氢原子的磁性来生成" }, { "key": [REDACTED_SECRET], "start_time": 14.77, "end_time": 15.97, "subtitle": "详细的内部图像" }, { "key": [REDACTED_SECRET], "start_time": 15.97, "end_time": 18.41, "subtitle": "为医学诊断提供了重要工具" } ] } } ``` Response field description: | First-level Field | Second-level Field | Third-level Field | Description | |---|---|---|---| | code | | | Response status code | | msg | | | Response message | | data | id | | Audio ID | | | type | | | | | status | | 1: generating; 9: completed | | | text | | Speech text | | | full | url | url to download the generated audio file | | | | path | | | | | duration | Audio duration | | | slice | | | | | errMsg | | Error message | | | errReason | | Error reason | | | subtitles (array type) | key | Subtitle ID | | | | start\_time | Subtitle start time | | | | end\_time | Subtitle end time | | | | subtitle | Subtitle text | Response status code description: | code | Description | |---|---| | 0 | Response successful | | 10400 | AccessToken verification failed | | 40000 | Parameter error | | 50000 | System internal error | ## Scripts 本 Skill 提供脚本（`skills/chanjing-tts/scripts/`），**带权限验证**：与 **chanjing-credentials-guard** 使用同一配置文件；无 AK/SK 时会**执行 guard 的 `open_login_page.py` 脚本**，在浏览器打开注册/登录页，并提示配置命令。 | 脚本 | 说明 | |------|------| | `list_voices.py` | 列出公共声音人，默认输出 id/name 表，可选 `--json` 输出完整数据 | | `create_task.py` | 创建 TTS 任务，输出 task_id | | `poll_task.py` | 轮询任务直到完成，输出音频下载 URL（full.url） | 示例（在项目根或 skill 目录下执行）： ```bash # 1. 列出可用声音，选取一个 id python skills/chanjing-tts/scripts/list_voices.py # 2. 创建合成任务 TASK_ID=$(python skills/chanjing-tts/scripts/create_task.py \ --audio-man [REDACTED_SECRET] \ --text "Hello, I am your AI assistant.") # 3. 轮询到完成，得到音频下载链接 python skills/chanjing-tts/scripts/poll_task.py --task-id "$TASK_ID" ```

path: scripts/_auth.py
content: # 鉴权：与 chanjing-credentials-guard 使用同一配置文件（CONFIG_DIR/credentials.json）
# 实现：AK/SK 校验、Token 校验与刷新；无 AK/SK 时执行 open_login_page.py 打开注册/登录页
import json
import os
import subprocess
import sys
import time
import urllib.request
from pathlib import Path

CONFIG_DIR = Path(os.environ.get("CHANJING_CONFIG_DIR", Path.home() / ".chanjing"))
CONFIG_FILE = CONFIG_DIR / "credentials.json"
API_BASE = os.environ.get("CHANJING_API_BASE", "https://open-api.chanjing.cc")
BUFFER_SECONDS = 300
LOGIN_URL = "https://www.chanjing.cc/openapi/login"

# 无 AK/SK 时先执行打开登录页脚本，再返回此提示
NO_CREDENTIALS_MSG = """已在浏览器打开蝉镜登录/注册页。
获取秘钥后请执行：
  python skills/chanjing-credentials-guard/scripts/chanjing_config.py --ak <你的app_id> --sk <你的secret_key>
设置完毕后请重新执行您之前的操作。"""

# AK/SK 无效（获取 Token 接口返回错误）时：不重试获取 token，执行打开登录页并提示重新配置
INVALID_CREDENTIALS_MSG = """AK/SK 无效或已过期，已在浏览器打开蝉镜登录/注册页。
请重新登录获取秘钥后执行：
  python skills/chanjing-credentials-guard/scripts/chanjing_config.py --ak <你的app_id> --sk <你的secret_key>
设置完毕后请重新执行您之前的操作。"""


def _run_open_login_page():
    """执行 credentials-guard 的 open_login_page.py，在默认浏览器打开注册/登录页。"""
    try:
        skills_dir = Path(__file__).resolve().parent.parent.parent
        script = skills_dir / "chanjing-credentials-guard" / "scripts" / "open_login_page.py"
        if script.exists():
            subprocess.run([sys.executable, str(script)], check=False, timeout=5)
        else:
            import webbrowser
            webbrowser.open(LOGIN_URL)
    except Exception:
        try:
            import webbrowser
            webbrowser.open(LOGIN_URL)
        except Exception:
            pass


def read_config():
    if CONFIG_FILE.exists():
        with open(CONFIG_FILE, "r", encoding="utf-8") as f:
            return json.load(f)
    return {}


def write_config(data):
    CONFIG_DIR.mkdir(parents=True, exist_ok=True)
    with open(CONFIG_FILE, "w", encoding="utf-8") as f:
        json.dump(data, f, indent=2, ensure_ascii=False)


def get_token():
    """返回 (token, None) 或 (None, error_msg)。"""
    data = read_config()
    app_id = (data.get("app_id") or "").strip()
    secret_key = (data.get("secret_key") or "").strip()
    if not app_id or not secret_key:
        _run_open_login_page()
        return None, NO_CREDENTIALS_MSG

    now = int(time.time())
    [REDACTED_SECRET]"access_token")
    expire_in = data.get("expire_in")
    try:
        expire_in = int(expire_in) if expire_in is not None else 0
    except (ValueError, TypeError):
        expire_in = 0

    if token and expire_in > now + BUFFER_SECONDS:
        return token, None

    url = API_BASE + "/open/v1/access_token"
    req = urllib.request.Request(
        url,
        data=json.dumps({"app_id": app_id, "secret_key": secret_key}).encode("utf-8"),
        headers={"Content-Type": "application/json"},
        method="POST",
    )
    try:
        with urllib.request.urlopen(req, timeout=30) as resp:
            body = json.loads(resp.read().decode("utf-8"))
    except Exception as e:
        return None, str(e)

    if body.get("code") != 0:
        _run_open_login_page()
        return None, INVALID_CREDENTIALS_MSG

    d = body.get("data", {})
    new_token = d.get("access_token")
    if not new_token:
        _run_open_login_page()
        return None, INVALID_CREDENTIALS_MSG

    data["access_token"] = new_token
    data["expire_in"] = d.get("expire_in")
    write_config(data)
    return new_token, None


path: scripts/create_task.py
content: #!/usr/bin/env python3
"""
创建 TTS 语音合成任务。与 chanjing-credentials-guard 使用同一配置文件获取 Token。
用法: create_task.py --audio-man <声音id> --text "要合成的文本" [--speed 1] [--pitch 1] [--aigc-watermark]
输出: task_id（一行）或错误到 stderr
"""
import argparse
import json
import sys
import urllib.request
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
from _auth import get_token

API_BASE = __import__("os").environ.get("CHANJING_API_BASE", "https://open-api.chanjing.cc")


def main():
    parser = argparse.ArgumentParser(description="创建 TTS 语音合成任务")
    parser.add_argument("--audio-man", required=True, help="声音 ID（来自 list_voices）")
    parser.add_argument("--text", required=True, help="要合成的文本，不超过 4000 字")
    parser.add_argument("--speed", type=float, default=1, help="语速 0.5-2，默认 1")
    parser.add_argument("--pitch", type=float, default=1, help="语调，默认 1")
    parser.add_argument("--aigc-watermark", action="store_true", help="是否添加 AIGC 水印")
    args = parser.parse_args()

    if len(args.text) > 4000:
        print("文本长度不能超过 4000 字符", file=sys.stderr)
        sys.exit(1)

    body = {
        "audio_man": args.audio_man.strip(),
        "speed": args.speed,
        "pitch": args.pitch,
        "text": {"text": args.text},
        "aigc_watermark": args.aigc_watermark,
    }

    token, err = get_token()
    if err:
        print(err, file=sys.stderr)
        sys.exit(1)

    url = f"{API_BASE}/open/v1/create_audio_task"
    req = urllib.request.Request(
        url,
        data=json.dumps(body).encode("utf-8"),
        headers={"access_token": token, "Content-Type": "application/json"},
        method="POST",
    )
    with urllib.request.urlopen(req, timeout=30) as resp:
        res = json.loads(resp.read().decode("utf-8"))

    if res.get("code") != 0:
        print(res.get("msg", res), file=sys.stderr)
        sys.exit(1)

    data = res.get("data", {})
    task_id = data.get("task_id")
    if not task_id:
        print("响应无 task_id", file=sys.stderr)
        sys.exit(1)
    print(task_id)


if __name__ == "__main__":
    main()


path: scripts/list_voices.py
content: #!/usr/bin/env python3
"""
列出公共声音人。与 chanjing-credentials-guard 使用同一配置文件获取 Token。
用法: list_voices.py [--page 1] [--size 100] [--json]
输出: 默认打印 id/name 表；--json 时输出完整 data
"""
import argparse
import json
import sys
import urllib.parse
import urllib.request
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
from _auth import get_token

API_BASE = __import__("os").environ.get("CHANJING_API_BASE", "https://open-api.chanjing.cc")


def main():
    parser = argparse.ArgumentParser(description="列出蝉镜公共声音人")
    parser.add_argument("--page", type=int, default=1, help="页码")
    parser.add_argument("--size", type=int, default=100, help="每页数量")
    parser.add_argument("--json", action="store_true", help="输出完整 JSON")
    args = parser.parse_args()

    token, err = get_token()
    if err:
        print(err, file=sys.stderr)
        sys.exit(1)

    qs = urllib.parse.urlencode({"page": args.page, "size": args.size})
    url = f"{API_BASE}/open/v1/list_common_audio?{qs}"
    req = urllib.request.Request(url, headers={"access_token": token}, method="GET")
    with urllib.request.urlopen(req, timeout=30) as resp:
        body = json.loads(resp.read().decode("utf-8"))

    if body.get("code") != 0:
        print(body.get("msg", body), file=sys.stderr)
        sys.exit(1)

    data = body.get("data", {})
    if args.json:
        print(json.dumps(data, ensure_ascii=False, indent=2))
        return

    items = data.get("list", [])
    page_info = data.get("page_info", {})
    print(f"# 共 {page_info.get('total_count', len(items))} 个声音 (page={args.page})")
    print(f"{'id':<36}  name")
    print("-" * 60)
    for v in items:
        print(f"{v.get('id', ''):<36}  {v.get('name', '')}")


if __name__ == "__main__":
    main()


path: scripts/poll_task.py
content: #!/usr/bin/env python3
"""
轮询 TTS 任务状态，直到完成或失败。与 chanjing-credentials-guard 使用同一配置文件获取 Token。
用法: poll_task.py --task-id <task_id> [--interval 3]
输出: 成功时打印音频下载 URL（data.full.url），失败时打印错误到 stderr 并 exit 1
状态: 1-生成中 9-完成
"""
import argparse
import json
import sys
import time
import urllib.request
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
from _auth import get_token

API_BASE = __import__("os").environ.get("CHANJING_API_BASE", "https://open-api.chanjing.cc")


def main():
    parser = argparse.ArgumentParser(description="轮询 TTS 任务直到完成")
    parser.add_argument("--task-id", required=True, help="任务 ID（来自 create_task）")
    parser.add_argument("--interval", type=int, default=3, help="轮询间隔秒数，默认 3")
    args = parser.parse_args()

    token, err = get_token()
    if err:
        print(err, file=sys.stderr)
        sys.exit(1)

    url = f"{API_BASE}/open/v1/audio_task_state"
    body = json.dumps({"task_id": args.task_id}).encode("utf-8")

    while True:
        req = urllib.request.Request(
            url,
            data=body,
            headers={"access_token": token, "Content-Type": "application/json"},
            method="POST",
        )
        with urllib.request.urlopen(req, timeout=30) as resp:
            res = json.loads(resp.read().decode("utf-8"))

        if res.get("code") != 0:
            print(res.get("msg", res), file=sys.stderr)
            sys.exit(1)

        data = res.get("data", {})
        status = data.get("status")

        if status == 9:
            full = data.get("full") or {}
            audio_url = full.get("url")
            if audio_url:
                print(audio_url)
                return 0
            print("任务完成但无 full.url", file=sys.stderr)
            sys.exit(1)

        if status not in (1, None):
            err_msg = data.get("errMsg") or data.get("errReason") or f"status={status}"
            print(f"任务失败: {err_msg}", file=sys.stderr)
            sys.exit(1)

        time.sleep(args.interval)


if __name__ == "__main__":
    main()


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