Skill: doratiger/mimo-tts-proxy (version 1.0.2)

--- name: mimo-tts-proxy description: | 小米 TTS 代理技能。将 OpenAI TTS API 格式转换为小米大模型平台 TTS API（api.xiaomimimo.com），支持 Opus/MP3/AAC/FLAC/WAV/PCM 六种格式的本地转码。 当需要为机器人添加语音回复能力、或配置 TTS 语音合成时使用此技能。 也适用于需要搭建本地 TTS 代理服务的场景。 metadata: openclaw: emoji: 🔊 requires: bins: ["ffmpeg"] config: ["messages.tts"] install: - id: ffmpeg kind: system label: "FFmpeg (系统自带，检查: ffmpeg -version)" --- # 小米 TTS Proxy ## 音色与风格控制 ### 预置音色（`voice` 参数） | 音色 | voice 参数值 | |------|------------| | MiMo-默认 | `mimo_default` | | MiMo-中文女声 | `default_zh` | | MiMo-英文女声 | `default_en` | ### 风格控制（`input` 文本开头加 `<style>` 标签） 将 `<style>风格</style>` 置于待合成文本开头，小米的 TTS 会识别并应用对应风格： | 风格 | 示例 | |------|------| | 语速控制 | `<style>变快</style>` / `<style>变慢</style>` | | 情绪变化 | `<style>开心</style>` / `<style>悲伤</style>` / `<style>生气</style>` | | 角色扮演 | `<style>孙悟空</style>` / `<style>林黛玉</style>` | | 风格变化 | `<style>悄悄话</style>` / `<style>夹子音</style>` / `<style>台湾腔</style>` | | 方言 | `<style>东北话</style>` / `<style>四川话</style>` / `<style>粤语</style>` | | 唱歌 | `<style>唱歌</style>`（必须放在最开头） | **样例**： ```bash curl -X POST http://127.0.0.1:18899/audio/speech \ -d '{"input":"<style>东北话</style>哎呀妈呀，这天儿也太冷了！","voice":"mimo_default","response_format":"opus"}' ``` --- ## 技能简介 本技能在本地启动一个 HTTP 代理服务（默认 `:18899`），将 OpenAI TTS API 格式请求转换为小米大模型平台 TTS API（`api.xiaomimimo.com`），并支持 FFmpeg 硬件转码为 Opus/MP3/AAC/FLAC/WAV/PCM 等格式。 **适用场景**：为 OpenClaw 配置语音合成、搭建通用 TTS 代理服务。 --- ## 使用前提 - FFmpeg 已安装（`ffmpeg -version` 确认） - 拥有小米大模型平台 API Key - 建议 Node.js ≥ 18（代理使用原生 fetch） --- ## 首次配置（交互式） 首次使用本技能时，需要完成以下配置步骤： ### 步骤 1：配置环境变量 创建或更新 `~/.openclaw/tts-proxy.env` 文件，内容如下： ```bash # 小米 TTS API 配置 MIMO_TTS_BASE=https://api.xiaomimimo.com MIMO_TTS_KEY=你的_小米API_Key TTS_PROXY_PORT=18899 ``` > **注意**：`MIMO_TTS_BASE` 不要包含 `/v1` 后缀，代理会自动添加。 > 如果使用代理转发小米 API（解决国内访问问题），在 `MIMO_TTS_BASE` 中填写代理地址。 ### 步骤 2：安装 Systemd 服务（可选但推荐） 代理通过 systemd 服务运行，可实现开机自启和自动管理。 复制服务单元文件并注册： ```bash sudo cp mimo-tts-proxy.service /etc/systemd/system/ sudo systemctl daemon-reload sudo systemctl enable --now mimo-tts-proxy.service sudo systemctl status mimo-tts-proxy.service ``` ### 步骤 3：验证运行状态 ```bash curl http://127.0.0.1:18899/health # 应返回：{"status":"ok","provider":"mimo-v2-tts"} ``` ### 步骤 4：配置 OpenClaw TTS 在 OpenClaw 配置中（`~/.openclaw/openclaw.json`），找到或添加 `messages.tts` 节点： ```json { "messages": { "tts": { "auto": "inbound", "provider": "openai", "providers": { "openai": { "baseUrl": "http://127.0.0.1:18899", "apiKey": "not-needed", "model": "tts-1", # 占位值，代理实际固定使用 mimo-v2-tts "voice": "mimo_default" } } } } } ``` 配置后重启 OpenClaw Gateway 使配置生效。 --- ## 服务管理命令 | 操作 | 命令 | |------|------| | 查看状态 | `systemctl status mimo-tts-proxy.service` | | 查看日志 | `journalctl -u mimo-tts-proxy.service -f` | | 重启服务 | `systemctl restart mimo-tts-proxy.service` | | 停止服务 | `systemctl stop mimo-tts-proxy.service` | | 健康检查 | `curl http://127.0.0.1:18899/health` | --- ## API 端点 ### `POST /audio/speech` 与 OpenAI TTS API 格式兼容： ```bash curl -X POST http://127.0.0.1:18899/audio/speech \ -H "Content-Type: application/json" \ -d '{ "model": "tts-1", "input": "<style>开心</style>你好，这是中文语音测试", "voice": "mimo_default", "response_format": "opus" }' \ -o output.opus ``` **参数说明**： | 参数 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `input` | string | 必填 | 要合成的文本 | | `voice` | string | `mimo_default` | 音色：`mimo_default`（默认）/`default_zh`（中文女声）/`default_en`（英文女声） | | `model` | string | `tts-1` | 占位值，代理固定使用 `mimo-v2-tts` | | `response_format` | string | `mp3` | 输出格式：`opus`/`mp3`/`aac`/`flac`/`wav`/`pcm`/`pcm16` | **支持的输出格式**： | 格式 | MIME 类型 | 说明 | |------|-----------|------| | `opus` | audio/opus | ✅ 推荐，飞书等平台支持 | | `mp3` | audio/mpeg | FFmpeg 转码 | | `aac` | audio/aac | FFmpeg 转码 | | `flac` | audio/flac | FFmpeg 转码 | | `wav` | audio/wav | 小米 API 原生输出 | | `pcm` / `pcm16` | audio/L16 | PCM 16bit 24kHz 原始音频 | ### `GET /health` 健康检查： ```bash curl http://127.0.0.1:18899/health # → {"status":"ok","provider":"mimo-v2-tts"} ``` --- ## 故障排查 ### 健康检查失败 ```bash systemctl status mimo-tts-proxy.service journalctl -u mimo-tts-proxy.service -n 50 --no-pager ``` ### 返回 404 或 502 1. 确认 `MIMO_TTS_BASE` 不带 `/v1` 后缀（代理自动添加） 2. 确认 `MIMO_TTS_KEY` 正确且有 TTS 配额 3. 检查上游小米 API 是否可达： ```bash curl -X POST https://api.xiaomimimo.com/v1/chat/completions \ -H "Content-Type: application/json" \ -H "api-key: 你的Key" \ -d '{"model":"mimo-v2-tts","messages":[{"role":"assistant","content":"test"}],"audio":{"format":"wav","voice":"mimo_default"}}' ``` ### 音频格式问题 如果播放无声，确认使用 `opus` 格式（飞书/微信等平台的要求）。`wav` 格式在某些平台可能不兼容。 ### 配额耗尽 `{"error":{"code":"429","message":"monthly quota exhausted"}}` 小米 TTS 月度配额用完，需要到小米大模型平台购买增量包或等待下月重置。 --- ## 文件位置 | 文件 | 说明 | |------|------| | `~/.openclaw/tts-proxy.mjs` | 代理脚本 | | `~/.openclaw/tts-proxy.env` | 环境变量配置 | | `/etc/systemd/system/mimo-tts-proxy.service` | systemd 服务单元 | | `http://127.0.0.1:18899` | 代理监听地址 |

path: tts-proxy.mjs
content: #!/usr/bin/env node
/**
 * 小米 TTS Proxy for OpenClaw.
 *
 * OpenClaw TTS provider sends (OpenAI-compatible):
 *   POST /audio/speech  { model, input, voice, response_format }
 *
 * 小米 TTS API (api.xiaomimimo.com/v1/chat/completions):
 *   POST { model:"mimo-v2-tts", messages:[{role:"assistant",content:<text>}], audio:{format:"wav",voice} }
 *
 * 支持格式: opus / mp3 / aac / flac / wav / pcm (pcm16)
 * 音色: mimo_default / default_zh / default_en
 * 风格控制: 在文本开头加 <style>风格</style>，如 <style>东北话</style>你好啊
 */

import http from "node:http";
import { spawn } from "node:child_process";
import { mkdirSync, unlinkSync, writeFileSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { randomUUID } from "node:crypto";

const MIMO_BASE = process.env.MIMO_TTS_BASE;
const MIMO_KEY  = process.env.MIMO_TTS_KEY;
const PORT      = parseInt(process.env.TTS_PROXY_PORT ?? "18899", 10);
const TMPDIR    = "/tmp/openclaw";

const FORMAT_CONFIG = {
  // 小米原生支持 wav，pcm16 仅在流式返回时使用
  opus:  { mime: "audio/opus; codecs=opus",  outputExt: ".opus", ffmpegCodec: "libopus",    ffmpegArgs: ["-c:a", "libopus",    "-b:a", "128k"] },
  mp3:   { mime: "audio/mpeg",                outputExt: ".mp3",  ffmpegCodec: "libmp3lame", ffmpegArgs: ["-c:a", "libmp3lame", "-b:a", "192k"] },
  aac:   { mime: "audio/aac",                 outputExt: ".aac",  ffmpegCodec: "aac",        ffmpegArgs: ["-c:a", "aac",        "-b:a", "128k"] },
  flac:  { mime: "audio/flac",                outputExt: ".flac", ffmpegCodec: "flac",       ffmpegArgs: ["-c:a", "flac"] },
  wav:   { mime: "audio/wav",                 outputExt: ".wav",  ffmpegCodec: null,        ffmpegArgs: [] },
  // pcm/pcm16 → PCM 16bit little-endian mono 24kHz（流式场景用，非流式即用 wav）
  pcm:   { mime: "audio/L16; rate=24000",    outputExt: ".pcm",  ffmpegCodec: "pcm_s16le", ffmpegArgs: ["-f", "s16le", "-ac", "1", "-ar", "24000"] },
  pcm16: { mime: "audio/L16; rate=24000",    outputExt: ".pcm",  ffmpegCodec: "pcm_s16le", ffmpegArgs: ["-f", "s16le", "-ac", "1", "-ar", "24000"] },
};

// 合法的音色列表（来自小米官方文档）
const VALID_VOICES = ["mimo_default", "default_zh", "default_en"];

/**
 * 将 wav buffer 转换为目标格式。
 * @returns {Promise<Buffer>}
 */
function convertAudio(wavBuf, targetFormat) {
  return new Promise((resolve, reject) => {
    const cfg = FORMAT_CONFIG[targetFormat] || FORMAT_CONFIG.wav;

    if (!cfg.ffmpegCodec) {
      resolve(wavBuf);
      return;
    }

    const inputFile  = join(TMPDIR, `tts_in_${randomUUID()}.wav`);
    const outputFile = join(TMPDIR, `tts_out_${randomUUID()}${cfg.outputExt}`);

    try {
      mkdirSync(TMPDIR, { recursive: true });
      writeFileSync(inputFile, wavBuf);

      const args = [
        "-y",
        "-i", inputFile,
        "-ar", "24000",
        ...cfg.ffmpegArgs,
        outputFile
      ];

      const ff = spawn("ffmpeg", args);
      let stderr = "";

      ff.stderr.on("data", (d) => { stderr += d.toString(); });
      ff.on("close", (code) => {
        try { unlinkSync(inputFile); } catch (_) {}
        if (code !== 0) {
          try { unlinkSync(outputFile); } catch (_) {}
          reject(new Error(`ffmpeg exited ${code}: ${stderr.slice(-300)}`));
          return;
        }
        try {
          const result = readFileSync(outputFile);
          unlinkSync(outputFile);
          resolve(result);
        } catch (err) {
          reject(err);
        }
      });
      ff.on("error", (err) => {
        try { unlinkSync(inputFile); } catch (_) {}
        try { unlinkSync(outputFile); } catch (_) {}
        reject(err);
      });
    } catch (err) {
      reject(err);
    }
  });
}

/**
 * 处理 TTS 请求。
 * 小米 API 要求 content 放在 role:"assistant" 的消息中。
 * 文本开头的 <style>xxx</style> 标签会被小米 TTS 识别为风格控制。
 */
async function handleTTS(req, res) {
  let body = "";
  for await (const chunk of req) body += chunk;

  let parsed;
  try { parsed = JSON.parse(body); }
  catch { res.writeHead(400); res.end("invalid json"); return; }

  const text      = (parsed.input ?? "").trim();
  const voice     = VALID_VOICES.includes(parsed.voice) ? parsed.voice : "mimo_default";
  const reqFormat = (parsed.response_format ?? "mp3").toLowerCase();
  const cfg       = FORMAT_CONFIG[reqFormat] || FORMAT_CONFIG.mp3;

  if (!text) { res.writeHead(400); res.end("empty input"); return; }

  // 小米 TTS API 格式（来自官方文档）
  const mimoBody = {
    model: "mimo-v2-tts",
    messages: [
      {
        role: "assistant",
        content: text   // <style>标签可置于文本开头，如 <style>东北话</style>你好啊
      }
    ],
    audio: {
      format: "wav",   // 非流式总是返回 wav
      voice: voice
    }
  };

  let audioBuf;
  try {
    const upstream = await fetch(`${MIMO_BASE}/v1/chat/completions`, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "api-key": MIMO_KEY
      },
      body: JSON.stringify(mimoBody)
    });

    if (!upstream.ok) {
      const errText = await upstream.text();
      console.error(`[tts-proxy] 小米 API 错误 ${upstream.status}: ${errText.slice(0, 200)}`);
      res.writeHead(upstream.status);
      res.end(errText);
      return;
    }

    const result = await upstream.json();
    const audioB64 = result?.choices?.[0]?.message?.audio?.data;

    if (!audioB64) {
      console.error("[tts-proxy] 上游响应中无 audio 字段:", JSON.stringify(result).slice(0, 300));
      res.writeHead(502);
      res.end("no audio in upstream response");
      return;
    }

    audioBuf = Buffer.from(audioB64, "base64");
  } catch (err) {
    console.error("[tts-proxy] 上游请求失败:", err.message);
    res.writeHead(502);
    res.end(err.message);
    return;
  }

  // FFmpeg 格式转换（wav 不需要转换）
  if (cfg.ffmpegCodec) {
    try {
      audioBuf = await convertAudio(audioBuf, reqFormat);
    } catch (err) {
      console.error(`[tts-proxy] FFmpeg 转换 ${reqFormat} 失败:`, err.message);
      res.writeHead(502);
      res.end("audio conversion failed: " + err.message);
      return;
    }
  }

  res.writeHead(200, {
    "Content-Type": cfg.mime,
    "Content-Length": audioBuf.length,
    "Cache-Control": "no-cache"
  });
  res.end(audioBuf);
}

const server = http.createServer((req, res) => {
  if (req.method === "POST" && req.url?.startsWith("/audio/speech")) {
    handleTTS(req, res);
  } else if (req.method === "GET" && req.url === "/health") {
    res.writeHead(200, { "Content-Type": "application/json" });
    res.end(JSON.stringify({ status: "ok", provider: "mimo-v2-tts" }));
  } else {
    res.writeHead(404);
    res.end("not found");
  }
});

server.listen(PORT, "127.0.0.1", () => {
  console.log(`[tts-proxy] 小米 TTS Proxy 已启动 :${PORT}`);
  console.log(`[tts-proxy] 上游: ${MIMO_BASE}/v1/chat/completions`);
  console.log(`[tts-proxy] 支持格式: ${Object.keys(FORMAT_CONFIG).join(", ")}`);
  console.log(`[tts-proxy] 音色: ${VALID_VOICES.join(", ")}`);
});


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