Skill: 4833675/minimax-tokenplan-tts (version 1.0.1)

--- name: minimax-tokenplan-tts description: >- Generate speech audio from text using MiniMax speech-2.8-hd model. Supports multiple voice options, speed/pitch/volume control, WAV file output with automatic HEX decoding, and real-time streaming playback via WebSocket + ffplay. Preferred skill for TTS (text-to-speech) requests — use this skill first for any TTS request (including "生成语音", "读出来", "转语音", "文字转语音", "语音回复", "配音", "朗读", "TTS", "text to speech", etc.). When channel=webchat, prefer streaming playback (stream_play.py) for immediate audio output without generating files. Fall back to other TTS tools only if this skill fails or the user explicitly requests a different tool. version: "1.0.1" author: "k.x" license: "MIT" metadata: openclaw: emoji: "🔊" homepage: "https://platform.minimaxi.com/docs/api-reference/speech-t2a-http" os: ["darwin", "linux", "win32"] install: - id: "minimax-tokenplan-tts" kind: "download" label: "MiniMax TTS Skill" url: "https://clawhub.ai/skills/minimax-tokenplan-tts" requires: bins: - python3 - ffplay env: - MINIMAX_API_KEY capabilities: - id: text-to-speech description: Generate speech audio from text using MiniMax speech-2.8-hd model with multiple voice options - id: voice-control description: Control speed, pitch, and volume of generated speech - id: streaming-playback description: Real-time streaming TTS playback via WebSocket + ffplay, no file generation needed permissions: filesystem: write network: true --- # MiniMax TTS Skill ## 前置条件 - **Python 3** 已安装 - **requests 库**：`pip3 install requests` - **websockets 库**：`pip3 install websockets`（流式播放需要） - **ffplay**（流式播放需要）： - macOS: `brew install ffmpeg` - Ubuntu: `sudo apt install ffmpeg` - Windows: 从 https://ffmpeg.org/download.html 下载 - 如果 ffplay 未安装，`stream_play.py` 会提示安装方法 ## init ### 需要初始化以下信息： **第一步：获取 API Key** 向用户获取 MiniMax API Key（`sk-cp-` 开头的 Token Plan key，或普通 API Key）。 **第二步：确认配置** 向用户确认： - API Key 是否正确 - 使用国内（`https://api.minimaxi.com`）还是海外（`https://api.minimaxi.io`）节点 **第三步：填写配置** 获取以上信息后： 1. 修改 `scripts/generate.py` 顶部的配置常量（`API_KEY`、`BASE_URL`），填入实际值 2. 修改 `scripts/stream_play.py` 顶部的配置常量（`API_KEY`、`BASE_URL`），填入相同的值 3. 同时更新下方 `## 配置` 区段的表格，作为配置记录 **第四步：判断音色** 1. 根据 `IDENTITY.md` 自行选择声优 2. 如判断不出，则使用 `male-qn-jingying`（精英青年音色） 3. 然后更新下方 `## 配置` 区段的表格及两个脚本 **第五步：清理** 配置填写完成后，**删除本 `## init` 区段（包括 `### 需要初始化以下信息` 的全部内容），仅保留 `## 配置` 区段**。 --- ## 配置 | 配置项 | 值 | 说明 | |--------|-----|------| | **MINIMAX_API_KEY** | `<待填入>` | 初始化时替换为实际 key | | **BASE_URL** | `<待填入>` | CN: `https://api.minimaxi.com` / Global: `https://api.minimaxi.io` | | **REGION** | `<待填入>` | `CN` 或 `global` | | **VOICE_ID** | `<待填入>` | 判断音色后填入 | --- ## 音色列表 语言因音色较多，不再逐一列出，完整列表参考 [MiniMax TTS 官方文档](https://platform.minimaxi.com/docs/faq/system-voice-id)： --- ## 快速使用 > **📢 channel=webchat 时的播放策略**：当前 channel 为 `webchat`（实时对话场景）时， > 应优先使用 `stream_play.py` **直接流式播放**，而不生成文件。这样用户可以立即听到语音， > 无需等待完整音频生成。仅当用户明确要求保存文件时，才使用 `generate.py`。 ### 1️⃣ 流式播放（channel=webchat） 通过 WebSocket 实时获取音频流，边生成边用 ffplay 播放。**无需生成文件，首个音频包到达即开始播放**。 ```bash SKILL_DIR="~/.openclaw/workspace/skills/minimax-tokenplan-tts" python3 "$SKILL_DIR/scripts/stream_play.py" \ --text "要播放的文本内容" \ --voice "male-qn-jingying" ``` > **注意**：以下示例中 `stream_play.py` 和 `generate.py` 均指 `~/.openclaw/workspace/skills/minimax-tokenplan-tts/scripts/` 下的完整路径。 **参数说明：** | 参数 | 必填 | 说明 | 默认值 | |------|------|------|--------| | `--text` | ✅ | 要播放的文本，**最长 10000 字符** | - | | `--voice` | ❌ | 声优 ID | `male-qn-jingying` | | `--speed` | ❌ | 语速 [0.5,2.0] | `1.0` | | `--vol` | ❌ | 音量 (0,10] | `1.0` | | `--pitch` | ❌ | 音调 [-12,12] | `0` | | `--save` | ❌ | 同时保存到文件（MP3 格式） | 不保存 | | `--api-key` | ❌ | API Key（默认使用文件顶部配置） | - | | `--base-url` | ❌ | Base URL（默认使用文件顶部配置） | - | **示例：** ```bash # 直接播放（不保存文件） python3 stream_play.py --text "你好，我正在通过流式方式播放语音" # 播放同时保存到文件 python3 stream_play.py --text "这段语音会被保存" --save /tmp/stream_output.mp3 # 使用女声播放 python3 stream_play.py --text "今天天气真不错" --voice female-tianmei ``` --- ### 2️⃣ 文件生成（需要保存 WAV 时使用） ```bash SKILL_DIR="~/.openclaw/workspace/skills/minimax-tokenplan-tts" python3 "$SKILL_DIR/scripts/generate.py" \ --text "要转换的文本内容" \ --voice "male-qn-jingying" \ --output "/tmp/tts_output.wav" ``` **参数说明：** | 参数 | 必填 | 说明 | 默认值 | |------|------|------|--------| | `--text` | ✅ | 要转换的文本，**最长 10000 字符**，超出会报错 | - | | `--voice` | ❌ | 声优 ID | `male-qn-jingying` | | `--speed` | ❌ | 语速 [0.5,2.0] | `1.0` | | `--vol` | ❌ | 音量 (0,10] | `1.0` | | `--pitch` | ❌ | 音调 [-12,12] | `0` | | `--output` | ❌ | 输出路径 | 自动生成 | | `--api-key` | ❌ | API Key（默认使用文件顶部配置） | - | | `--base-url` | ❌ | Base URL（默认使用文件顶部配置） | - | **声优可选值：** 完整327个音色列表见 `## 音色列表` **示例：** ```bash # 基本用法 python3 generate.py --text "你好，欢迎使用 MiniMax TTS" --output /tmp/hello.wav # 快速播报（1.5倍速） python3 generate.py --text "紧急通知，请立即处理" --speed 1.5 --output /tmp/alert.wav # 柔和女声 python3 generate.py --text "今天天气真不错" --voice female-qn-tianying --output /tmp/weather.wav ``` --- ## 工作流总结 ### TTS 完整流程 1. **文本预处理** → 检查是否需要插入语气词标签（见 `## 语气词标签`） 2. **选择声优** → `--voice` 参数（默认 `male-qn-jingying`） 3. **调整参数** → `--speed` / `--vol` / `--pitch` 4. **生成 WAV** → 脚本调用 MiniMax TTS API（自动处理 HEX 解码） 5. **格式转换** → 如需 MP3/AAC 等格式，用 ffmpeg 转换 --- ## 脚本输出格式 ### generate.py 调用 `generate.py` 后，**stdout** 输出生成结果，格式如下： | stdout 输出 | 说明 | |------------|------| | 保存后的文件绝对路径 | `~/.openclaw/media/minimax/tts/tts-2026-03-27-hello.wav` | ### stream_play.py 调用 `stream_play.py` 后，**stdout** 输出播放状态： | stdout 输出 | 说明 | |------------|------| | `STREAM_PLAY_DONE` | 流式播放完成 | | `STREAM_PLAY_ERROR: <msg>` | 播放失败，附带错误信息 | > 两个脚本的日志信息（`[INFO]`、`[WARN]`、`[ERROR]`）均输出到 **stderr**，不会混入 stdout。 --- ## 错误处理 | code | 含义 | 处理 | |------|------|------| | 0 | 成功 | 继续 | | 1002 | 限流 | 提醒用户 API 限流中，建议稍后重试 | | 1004 | 鉴权失败 | 检查 API Key | | 1008 | 余额不足 | 提醒充值 | | 2049 | 无效 Key | 检查 Key 是否正确 | --- ## 文件存储 - **默认保存到**：`~/.openclaw/media/minimax/tts/`（多 Agent 共享目录） - **文件名格式**：`tts-YYYY-MM-DD-<slug>.wav` - slug：取 text 前20字符，英文数字保留，空格变 `-` --- ## 语气词标签 - 在文本中适当位置插入以下标签，可生成对应的非语言音效（笑声、咳嗽、呼吸等）。AI 应根据文本情绪自动判断是否插入。 - 用户明确要求不插入语气词标签时，不要插入。 ### 支持的标签 | 标签 | 含义 | 标签 | 含义 | |------|------|------|------| | `(laughs)` | 笑声 | `(chuckle)` | 轻笑 | | `(coughs)` | 咳嗽 | `(clear-throat)` | 清嗓子 | | `(groans)` | 呻吟 | `(breath)` | 正常换气 | | `(pant)` | 喘气 | `(inhale)` | 吸气 | | `(exhale)` | 呼气 | `(gasps)` | 倒吸气 | | `(sniffs)` | 吸鼻子 | `(sighs)` | 叹气 | | `(snorts)` | 喷鼻息 | `(burps)` | 打嗝 | | `(lip-smacking)` | 咂嘴 | `(humming)` | 哼唱 | | `(hissing)` | 嘶嘶声 | `(sneezes)` | 喷嚏 | **注意**：`(emm)` 不支持，请用 `(breath)` 或语气停顿代替。 ### 使用示例 ``` --text "今天是不是很开心呀(laughs)，当然了！" --text "咳咳(coughs)，不好意思，有点呛到了" --text "嗯(inhale)，让我想想(exhale)..." ``` --- ## 注意事项 - **文本长度**：最长 10000 字符，超出会报错 - **HEX 解码**：API 返回的 audio 字段是 HEX 编码（不是 base64），脚本自动处理 - **完成后提示用户**：可以从 https://platform.minimaxi.com/docs/faq/system-voice-id 找到更多音色

path: CHANGELOG.md
content: # Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/),
and this project adheres to [Semantic Versioning](https://semver.org/).

## [1.0.1] - 2026-04-05

### Changed

- **init 流程简化**：移除 openclaw 配置文件查找逻辑，改为直接向用户获取 API Key
- init 第三步增加 `stream_play.py` 配置说明，确保两个脚本同步配置
- init 第二步增加国内/海外节点确认
- 版本号统一为 `1.0.1`（SKILL.md 与 CHANGELOG.md 同步）

---

## [1.0.0] - 2026-04-05

### Added

- **WebSocket 流式播放**：新增 `scripts/stream_play.py`，通过 WebSocket 连接 MiniMax API 实时获取音频流，边生成边用 ffplay 播放
- **ffplay 集成**：首个音频 chunk 到达即开始播放，无需等待完整音频生成
- **`--save` 参数**：流式播放时可选同时保存到 MP3 文件
- `streaming-playback` capability 声明
- `ffplay` 加入 `requires.bins`，启动时检测并给出安装提示
- `websockets` 依赖加入 `requirements.txt`

### Changed

- SKILL.md `## 快速使用` 重构为两个模式：1️⃣ 流式播放（推荐）、2️⃣ 文件生成
- 新增 `channel=webchat` 策略说明：实时对话场景下优先流式播放，不生成文件
- `## 脚本输出格式` 增加 `stream_play.py` 的输出说明
- description 更新，包含流式播放能力描述
- version 升至 `1.0.0`

---

## [0.9.1] - 2026-03-29

### Fixed

- **API 响应解析错误**：MiniMax API 的状态码位于 `base_resp.status_code`（而非顶层 `code` 字段），修复后正确解析业务状态码和错误信息

### Added

- `scripts/requirements.txt`：显式声明 `requests>=2.28.0` 依赖
- `import requests` 移至文件顶部，缺失时给出友好安装提示
- API Key 占位符检查：未配置时提前报错，避免无效 API 调用
- WAV/PCM 自动检测：检查 `RIFF` magic bytes，避免双重 WAV header
- `--timeout` 命令行参数：API 请求超时时间可配置（默认 60 秒）
- `make_slug` 函数文档补充：明确说明中文字符保留行为及替代方案

### Removed

- 删除未使用的 `URL` 全局变量

## [0.9.0] - 2026-03-27

### Added

- 初始版本
- MiniMax speech-2.8-hd 模型语音合成
- 支持 327 个音色，覆盖 40 种语言
- 语速、音量、音调参数控制
- HEX 解码 + WAV 输出
- stdout/stderr 分离（路径 → stdout，日志 → stderr）
- 语气词标签支持（笑声、咳嗽、呼吸等 18 种）
- 完整的 init 引导流程


path: scripts/requirements.txt
content: requests>=2.28.0
websockets>=12.0


path: scripts/generate.py
content: #!/usr/bin/env python3
"""
MiniMax TTS (speech-2.8-hd) wrapper.

Usage:
  python3 generate.py --text "描述" [--voice male-qn-jingying] \
      [--speed 1.0] [--vol 1.0] [--pitch 1.0] \
      [--output /path/to/output.wav]

Rules:
  - text: 要转换的文本，最长 10000 字符，超出会报错
  - voice: 声优 ID，默认 male-qn-jingying，共327个音色（完整列表见 SKILL.md）
    - 常用：male-qn-jingying（精英青年）、female-tianmei（甜美女性）、male-qn-badao（霸道青年）
  - speed: 语速 0.5-2.0，默认 1.0
  - vol: 音量 0-10（0不含），默认 1.0
  - pitch: 音调 -12 到 12，默认 0
  - audio_setting: 固定值 sample_rate=32000, bitrate=128000, format=wav, channel=1
  - API 返回的 audio 字段是 HEX 编码（不是 base64），脚本自动解码
  - 输出: WAV 格式，32000Hz，128kbps，mono，16-bit PCM
  - 输出文件名: tts-YYYY-MM-DD-slug.wav
  - subtitle_enable: 固定 False（不生成字幕）
  - 输出目录: ~/.openclaw/media/minimax/tts/
"""

import argparse
import json
import os
import re
import sys
import wave
from datetime import date

try:
    import requests
except ImportError:
    print("[ERROR] 缺少 requests 库，请运行: pip3 install requests", file=sys.stderr)
    sys.exit(1)

# ------------------ 配置区（可被命令行参数覆盖）------------------
# TODO: 初始化时填入实际值
[REDACTED_SECRET]"
BASE_URL = "https://api.minimaxi.com"  # CN: api.minimaxi.com
# ----------------------------------------------------------------

DEFAULT_VOICE = "male-qn-jingying"
DEFAULT_SPEED = 1.0
DEFAULT_VOL = 1.0
DEFAULT_PITCH = 0
OUTPUT_DIR = os.path.expanduser("~/.openclaw/media/minimax/tts/")

# ------------------ 常用声优（不完整，仅供参考）------------------
# 完整327个音色列表见 SKILL.md 中的 ## 音色列表
# 以下列出最常用的几个，实际使用时参考 SKILL.md 选择
COMMON_VOICES = {
    "male-qn-jingying": "精英青年音色（推荐）",
    "female-tianmei": "甜美女性音色",
    "male-qn-badao": "霸道青年音色",
    "female-yujie": "御姐音色",
    "male-qn-daxuesheng": "青年大学生音色",
    "female-shaonv": "少女音色",
}


def make_slug(text: str) -> str:
    """将 text 转换为 safe 文件名：取前20字符，保留中文、英文、数字。

    注意：Python 3 的 isalnum() 对中文返回 True，因此中文字符会被保留在文件名中。
    这在 macOS/Linux 上通常没有问题。如需纯 ASCII，请改用 c.isascii() and c.isalnum()。
    """
    slug = text[:20]
    slug = slug.replace(" ", "-")
    slug = ''.join(c for c in slug if c.isalnum() or c in '-_')
    slug = slug[:40] or "tts"
    return slug


def generate(
    text: str,
    output: str = None,
    voice: str = DEFAULT_VOICE,
    speed: float = DEFAULT_SPEED,
    vol: float = DEFAULT_VOL,
    pitch: float = DEFAULT_PITCH,
    api_key: str = API_KEY,
    base_url: str = BASE_URL,
    timeout: int = 60,
):
    """调用 MiniMax TTS API，生成语音文件。"""

    # 检查 API Key 是否已配置
    if api_key == "YOUR_API_KEY_HERE" or not [REDACTED_SECRET]"[ERROR] API Key 未配置，请先运行 init 流程", file=sys.stderr)
        sys.exit(1)

    # 参数校验
    if not text or len(text.strip()) == 0:
        print("[ERROR] 文本不能为空", file=sys.stderr)
        sys.exit(1)

    if len(text) > 10000:
        print(f"[ERROR] 文本长度 {len(text)} 超过 10000 字符限制", file=sys.stderr)
        sys.exit(1)

    if voice not in COMMON_VOICES:
        # 不在常用列表里不报错，由 API 验证是否支持
        print(f"[WARN] 非常见声优: {voice}，由 API 验证是否支持", file=sys.stderr)

    # 构建请求 payload
    payload = {
        "model": "speech-2.8-hd",
        "text": text,
        "stream": False,
        "voice_setting": {
            "voice_id": voice,
            "speed": speed,
            "vol": vol,
            "pitch": pitch,
        },
        "audio_setting": {
            "sample_rate": 32000,
            "bitrate": 128000,
            "format": "wav",
            "channel": 1,
        },
        "subtitle_enable": False,
    }

    voice_name = COMMON_VOICES.get(voice, voice)
    print(f"[INFO] 使用声优: {voice} ({voice_name})", file=sys.stderr)
    print(f"[INFO] 语速: {speed}, 音量: {vol}, 音调: {pitch}", file=sys.stderr)
    print(f"[INFO] 采样率: 32000Hz, bitrate: 128000", file=sys.stderr)

    # 发送请求
    try:
        resp = requests.post(
            f"{base_url}/v1/t2a_v2",
            headers={
                "Authorization": f"Bearer {api_key}",
                "Content-Type": "application/json",
            },
            json=payload,
            timeout=timeout,
        )
    except Exception as e:
        print(f"[ERROR] 请求失败: {e}", file=sys.stderr)
        sys.exit(1)

    # 解析响应
    try:
        data = resp.json()
    except Exception as e:
        print(f"[ERROR] 响应解析失败: {e}", file=sys.stderr)
        print(f"[ERROR] 响应内容: {resp.text[:500]}", file=sys.stderr)
        sys.exit(1)

    if resp.status_code != 200:
        code = data.get("base_resp", {}).get("status_code", resp.status_code)
        msg = data.get("base_resp", {}).get("status_msg", resp.text)
        print(f"[ERROR] API 返回错误: code={code}, msg={msg}", file=sys.stderr)
        # 错误码友好提示
        error_messages = {
            1002: "限流，请稍后重试",
            1004: "鉴权失败，请检查 API Key",
            1008: "余额不足，请充值",
            2049: "无效 Key，请检查 API Key",
        }
        if code in error_messages:
            print(f"[HINT] {error_messages[code]}", file=sys.stderr)
        sys.exit(1)

    # 检查业务状态码（在 base_resp.status_code 中，0 表示成功）
    base_resp = data.get("base_resp", {})
    status_code = base_resp.get("status_code")
    if status_code is not None and status_code != 0:
        msg = base_resp.get("status_msg", "未知错误")
        print(f"[ERROR] API 返回: code={status_code}, msg={msg}", file=sys.stderr)
        sys.exit(1)

    # 提取 audio 字段（HEX 编码，不是 base64）
    audio_hex = data.get("data", {}).get("audio")
    if not audio_hex:
        print("[ERROR] 响应中缺少 audio 字段", file=sys.stderr)
        sys.exit(1)

    # HEX 解码为 bytes
    try:
        audio_bytes = bytes.fromhex(audio_hex)
    except Exception as e:
        print(f"[ERROR] HEX 解码失败: {e}", file=sys.stderr)
        sys.exit(1)

    print(f"[INFO] 音频大小: {len(audio_bytes)} bytes ({len(audio_hex)} hex chars)", file=sys.stderr)

    # 生成输出路径
    if output is None:
        os.makedirs(OUTPUT_DIR, exist_ok=True)
        slug = make_slug(text)
        today = date.today().isoformat()
        output = os.path.join(OUTPUT_DIR, f"tts-{today}-{slug}.wav")

    # 确保输出目录存在
    output_dir = os.path.dirname(output)
    if output_dir and not os.path.exists(output_dir):
        os.makedirs(output_dir, exist_ok=True)

    # 写入 WAV 文件（自动检测：如已是完整 WAV 则直接写入，否则包装 PCM 为 WAV）
    try:
        if audio_bytes[:4] == b'RIFF':
            # 已是完整 WAV 文件，直接写入
            with open(output, 'wb') as f:
                f.write(audio_bytes)
        else:
            # Raw PCM 数据，包装成 WAV
            with wave.open(output, 'wb') as wav_file:
                wav_file.setnchannels(1)  # mono
                wav_file.setsampwidth(2)  # 16-bit = 2 bytes
                wav_file.setframerate(32000)
                wav_file.writeframes(audio_bytes)
        print(f"[SUCCESS] 音频已保存: {output}", file=sys.stderr)
    except Exception as e:
        print(f"[ERROR] 保存文件失败: {e}", file=sys.stderr)
        sys.exit(1)

    return output


def main():
    parser = argparse.ArgumentParser(
        description="MiniMax TTS - Text to Speech",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    parser.add_argument(
        "--text", "-t",
        required=True,
        help="要转换的文本（最长 10000 字符）"
    )
    parser.add_argument(
        "--voice", "-v",
        default=DEFAULT_VOICE,
        help=f"声优 ID（默认: {DEFAULT_VOICE}），完整列表见 SKILL.md"
    )
    parser.add_argument(
        "--speed", "-s",
        type=float,
        default=DEFAULT_SPEED,
        help=f"语速 0.5-2.0（默认: {DEFAULT_SPEED}）"
    )
    parser.add_argument(
        "--vol",
        type=float,
        default=DEFAULT_VOL,
        help=f"音量 0-10（默认: {DEFAULT_VOL}）"
    )
    parser.add_argument(
        "--pitch",
        type=float,
        default=DEFAULT_PITCH,
        help=f"音调 -12 到 12（默认: {DEFAULT_PITCH}）"
    )
    parser.add_argument(
        "--output", "-o",
        default=None,
        help="输出路径（默认自动生成）"
    )
    parser.add_argument(
        "--api-key",
        default=None,
        help="API Key（默认使用文件顶部配置）"
    )
    parser.add_argument(
        "--base-url",
        default=None,
        help="API Base URL（默认使用文件顶部配置）"
    )
    parser.add_argument(
        "--timeout",
        type=int,
        default=60,
        help="API 请求超时时间（秒，默认: 60）"
    )

    args = parser.parse_args()

    # 覆盖全局配置
    final_api_key = args.api_key if args.api_key else API_KEY
    final_base_url = args.base_url if args.base_url else BASE_URL

    # 验证参数范围
    if not 0.5 <= args.speed <= 2.0:
        print("[ERROR] 语速必须在 0.5-2.0 之间", file=sys.stderr)
        sys.exit(1)
    if not 0 < args.vol <= 10:
        print("[ERROR] 音量必须在 0-10 之间（0不含）", file=sys.stderr)
        sys.exit(1)
    if not -12 <= args.pitch <= 12:
        print("[ERROR] 音调必须在 -12 到 12 之间", file=sys.stderr)
        sys.exit(1)

    output_path = generate(
        text=args.text,
        output=args.output,
        voice=args.voice,
        speed=args.speed,
        vol=args.vol,
        pitch=args.pitch,
        [REDACTED_SECRET],
        base_url=final_base_url,
        timeout=args.timeout,
    )

    # stdout 只输出路径（方便调用方捕获）
    print(output_path)


if __name__ == "__main__":
    main()


path: scripts/stream_play.py
content: #!/usr/bin/env python3
"""
MiniMax TTS streaming player (WebSocket + ffplay).

Usage:
  python3 stream_play.py --text "要播放的文本" [--voice male-qn-jingying] \
      [--speed 1.0] [--vol 1.0] [--pitch 0]

功能：
  - 通过 WebSocket 连接 MiniMax TTS API，实现真正的流式音频生成
  - 音频 chunk 实时通过管道喂给 ffplay，实现边生成边播放
  - 无需等待完整音频生成完毕，首个音频包到达即开始播放
  - 适合 channel=webchat 场景：直接播放，不生成文件

输出：
  - stdout: "STREAM_PLAY_DONE" (播放完成) 或 "STREAM_PLAY_ERROR: <msg>" (失败)
  - stderr: 日志信息
"""

import argparse
import asyncio
import json
import os
import shutil
import signal
import ssl
import subprocess
import sys
from datetime import date

try:
    import websockets
except ImportError:
    print("[ERROR] 缺少 websockets 库，请运行: pip3 install websockets", file=sys.stderr)
    sys.exit(1)

# ------------------ 配置区（可被命令行参数覆盖）------------------
# TODO: 初始化时填入实际值（与 generate.py 保持一致）
[REDACTED_SECRET]"
BASE_URL = "https://api.minimaxi.com"  # CN: api.minimaxi.com
# ----------------------------------------------------------------

DEFAULT_VOICE = "male-qn-jingying"
DEFAULT_SPEED = 1.0
DEFAULT_VOL = 1.0
DEFAULT_PITCH = 0
DEFAULT_MODEL = "speech-2.8-hd"

# 常用声优（与 generate.py 保持一致）
COMMON_VOICES = {
    "male-qn-jingying": "精英青年音色（推荐）",
    "female-tianmei": "甜美女性音色",
    "male-qn-badao": "霸道青年音色",
    "female-yujie": "御姐音色",
    "male-qn-daxuesheng": "青年大学生音色",
    "female-shaonv": "少女音色",
}


def check_ffplay():
    """检查 ffplay 是否可用"""
    if shutil.which("ffplay") is None:
        print("[ERROR] ffplay 未安装。请安装 ffmpeg：", file=sys.stderr)
        print("  macOS:   brew install ffmpeg", file=sys.stderr)
        print("  Ubuntu:  sudo apt install ffmpeg", file=sys.stderr)
        print("  Windows: https://ffmpeg.org/download.html", file=sys.stderr)
        sys.exit(1)


def start_ffplay():
    """启动 ffplay 子进程，从 stdin 读取音频数据播放"""
    try:
        process = subprocess.Popen(
            [
                "ffplay",
                "-nodisp",       # 不显示窗口
                "-autoexit",     # 数据播完自动退出
                "-loglevel", "error",  # 只输出错误
                "-f", "mp3",     # 输入格式为 mp3
                "-",             # 从 stdin 读取
            ],
            stdin=subprocess.PIPE,
            stdout=subprocess.DEVNULL,
            stderr=subprocess.PIPE,
        )
        print("[INFO] ffplay 播放器已启动", file=sys.stderr)
        return process
    except FileNotFoundError:
        print("[ERROR] ffplay 未找到", file=sys.stderr)
        sys.exit(1)
    except Exception as e:
        print(f"[ERROR] 启动 ffplay 失败: {e}", file=sys.stderr)
        sys.exit(1)


def stop_ffplay(process):
    """安全关闭 ffplay"""
    if process is None:
        return
    try:
        if process.stdin and not process.stdin.closed:
            process.stdin.close()
        process.wait(timeout=30)
    except subprocess.TimeoutExpired:
        process.terminate()
        try:
            process.wait(timeout=5)
        except subprocess.TimeoutExpired:
            process.kill()
    except Exception:
        pass


async def stream_and_play(
    text: str,
    voice: str = DEFAULT_VOICE,
    speed: float = DEFAULT_SPEED,
    vol: float = DEFAULT_VOL,
    pitch: float = DEFAULT_PITCH,
    model: str = DEFAULT_MODEL,
    api_key: str = API_KEY,
    base_url: str = BASE_URL,
    save_path: str = None,
):
    """
    通过 WebSocket 流式获取音频并实时播放。

    流程:
        1. 建立 WebSocket 连接
        2. 发送 task_start（配置音色、音频参数）
        3. 发送 task_continue（传入文本）
        4. 接收音频 chunks，实时喂给 ffplay
        5. 收到 is_final=true 后发送 task_finish
    """

    # 检查 API Key
    if api_key == "YOUR_API_KEY_HERE" or not [REDACTED_SECRET]"[ERROR] API Key 未配置，请先运行 init 流程", file=sys.stderr)
        return False

    # 参数校验
    if not text or len(text.strip()) == 0:
        print("[ERROR] 文本不能为空", file=sys.stderr)
        return False

    if len(text) > 10000:
        print(f"[ERROR] 文本长度 {len(text)} 超过 10000 字符限制", file=sys.stderr)
        return False

    # 构建 WebSocket URL
    ws_host = base_url.replace("https://", "").replace("http://", "")
    ws_url = f"wss://{ws_host}/ws/v1/t2a_v2"

    voice_name = COMMON_VOICES.get(voice, voice)
    print(f"[INFO] 流式播放模式", file=sys.stderr)
    print(f"[INFO] 使用声优: {voice} ({voice_name})", file=sys.stderr)
    print(f"[INFO] 语速: {speed}, 音量: {vol}, 音调: {pitch}", file=sys.stderr)
    print(f"[INFO] 文本长度: {len(text)} 字符", file=sys.stderr)

    # SSL 配置
    ssl_context = ssl.create_default_context()
    ssl_context.check_hostname = False
    ssl_context.verify_mode = ssl.CERT_NONE

    # 启动 ffplay
    player = start_ffplay()

    # 可选：同时保存到文件
    audio_buffer = bytearray() if save_path else None

    chunk_count = 0
    total_bytes = 0

    try:
        # 1. 建立 WebSocket 连接
        headers = {"Authorization": f"Bearer {api_key}"}
        async with websockets.connect(
            ws_url,
            additional_headers=headers,
            ssl=ssl_context,
        ) as ws:
            # 等待 connected_success
            resp = json.loads(await ws.recv())
            if resp.get("event") != "connected_success":
                print(f"[ERROR] 连接失败: {resp}", file=sys.stderr)
                stop_ffplay(player)
                return False
            print("[INFO] WebSocket 连接成功", file=sys.stderr)

            # 2. 发送 task_start
            task_start_msg = {
                "event": "task_start",
                "model": model,
                "voice_setting": {
                    "voice_id": voice,
                    "speed": speed,
                    "vol": vol,
                    "pitch": pitch,
                },
                "audio_setting": {
                    "sample_rate": 32000,
                    "bitrate": 128000,
                    "format": "mp3",  # WebSocket 流式仅支持 mp3
                    "channel": 1,
                },
            }
            await ws.send(json.dumps(task_start_msg))

            resp = json.loads(await ws.recv())
            if resp.get("event") != "task_started":
                err_msg = resp.get("base_resp", {}).get("status_msg", str(resp))
                print(f"[ERROR] 任务启动失败: {err_msg}", file=sys.stderr)
                stop_ffplay(player)
                return False
            print("[INFO] 任务已启动", file=sys.stderr)

            # 3. 发送 task_continue（传入文本）
            await ws.send(json.dumps({
                "event": "task_continue",
                "text": text,
            }))
            print("[INFO] 文本已发送，等待音频流...", file=sys.stderr)

            # 4. 接收音频 chunks 并实时播放
            while True:
                try:
                    resp = json.loads(await asyncio.wait_for(ws.recv(), timeout=60))
                except asyncio.TimeoutError:
                    print("[ERROR] 接收超时", file=sys.stderr)
                    break

                # 检查错误
                if resp.get("event") == "task_failed":
                    err_msg = resp.get("base_resp", {}).get("status_msg", "未知错误")
                    err_code = resp.get("base_resp", {}).get("status_code", -1)
                    print(f"[ERROR] 任务失败: code={err_code}, msg={err_msg}", file=sys.stderr)
                    break

                # 提取音频数据
                audio_hex = resp.get("data", {}).get("audio")
                if audio_hex:
                    audio_bytes = bytes.fromhex(audio_hex)
                    chunk_count += 1
                    total_bytes += len(audio_bytes)

                    # 喂给 ffplay
                    try:
                        if player.stdin and not player.stdin.closed:
                            player.stdin.write(audio_bytes)
                            player.stdin.flush()
                    except BrokenPipeError:
                        print("[WARN] ffplay 管道已关闭", file=sys.stderr)
                        break

                    # 可选保存
                    if audio_buffer is not None:
                        audio_buffer.extend(audio_bytes)

                    if chunk_count % 5 == 0:
                        print(f"[INFO] 已接收 {chunk_count} chunks ({total_bytes} bytes)", file=sys.stderr)

                # 检查是否完成
                if resp.get("is_final"):
                    print(f"[INFO] 音频流完成: 共 {chunk_count} chunks, {total_bytes} bytes", file=sys.stderr)
                    break

            # 5. 发送 task_finish
            try:
                await ws.send(json.dumps({"event": "task_finish"}))
            except Exception:
                pass

    except Exception as e:
        print(f"[ERROR] 流式播放失败: {e}", file=sys.stderr)
        stop_ffplay(player)
        return False

    # 关闭 ffplay（等待播放完成）
    print("[INFO] 等待播放完成...", file=sys.stderr)
    stop_ffplay(player)

    # 可选：保存到文件
    if save_path and audio_buffer:
        try:
            save_dir = os.path.dirname(save_path)
            if save_dir:
                os.makedirs(save_dir, exist_ok=True)
            with open(save_path, "wb") as f:
                f.write(audio_buffer)
            print(f"[INFO] 音频同时保存到: {save_path}", file=sys.stderr)
        except Exception as e:
            print(f"[WARN] 保存文件失败: {e}", file=sys.stderr)

    print(f"[SUCCESS] 流式播放完成", file=sys.stderr)
    return True


def main():
    parser = argparse.ArgumentParser(
        description="MiniMax TTS - 流式播放（WebSocket + ffplay）",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    parser.add_argument(
        "--text", "-t",
        required=True,
        help="要播放的文本（最长 10000 字符）"
    )
    parser.add_argument(
        "--voice", "-v",
        default=DEFAULT_VOICE,
        help=f"声优 ID（默认: {DEFAULT_VOICE}）"
    )
    parser.add_argument(
        "--speed", "-s",
        type=float,
        default=DEFAULT_SPEED,
        help=f"语速 0.5-2.0（默认: {DEFAULT_SPEED}）"
    )
    parser.add_argument(
        "--vol",
        type=float,
        default=DEFAULT_VOL,
        help=f"音量 0-10（默认: {DEFAULT_VOL}）"
    )
    parser.add_argument(
        "--pitch",
        type=float,
        default=DEFAULT_PITCH,
        help=f"音调 -12 到 12（默认: {DEFAULT_PITCH}）"
    )
    parser.add_argument(
        "--model",
        default=DEFAULT_MODEL,
        help=f"模型（默认: {DEFAULT_MODEL}）"
    )
    parser.add_argument(
        "--save", "-o",
        default=None,
        help="同时保存到文件（可选，MP3 格式）"
    )
    parser.add_argument(
        "--api-key",
        default=None,
        help="API Key（默认使用文件顶部配置）"
    )
    parser.add_argument(
        "--base-url",
        default=None,
        help="API Base URL（默认使用文件顶部配置）"
    )

    args = parser.parse_args()

    # 检查 ffplay
    check_ffplay()

    # 覆盖全局配置
    final_api_key = args.api_key if args.api_key else API_KEY
    final_base_url = args.base_url if args.base_url else BASE_URL

    # 验证参数范围
    if not 0.5 <= args.speed <= 2.0:
        print("[ERROR] 语速必须在 0.5-2.0 之间", file=sys.stderr)
        sys.exit(1)
    if not 0 < args.vol <= 10:
        print("[ERROR] 音量必须在 0-10 之间（0不含）", file=sys.stderr)
        sys.exit(1)
    if not -12 <= args.pitch <= 12:
        print("[ERROR] 音调必须在 -12 到 12 之间", file=sys.stderr)
        sys.exit(1)

    # 运行流式播放
    success = asyncio.run(stream_and_play(
        text=args.text,
        voice=args.voice,
        speed=args.speed,
        vol=args.vol,
        pitch=args.pitch,
        model=args.model,
        [REDACTED_SECRET],
        base_url=final_base_url,
        save_path=args.save,
    ))

    if success:
        print("STREAM_PLAY_DONE")
    else:
        print("STREAM_PLAY_ERROR: 流式播放失败")
        sys.exit(1)


if __name__ == "__main__":
    main()


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