Skill: wglnngt/xunfei-voice-reply (version 1.0.0)

--- name: xunfei-voice-reply description: 语音回复技能 - 使用讯飞 TTS 生成语音并发送到飞书。当需要用语音回复用户消息时使用。触发词：用语音、语音回复、切换语音模式、语音模式。 --- # 语音回复技能 ## 模式切换 用户可以通过以下命令切换回复模式： - **"用语音"** → 切换到语音模式 - **"用文字"** → 切换到文字模式 切换时更新 `USER.md` 的 `回复模式` 字段。 ## 语音回复流程 当 `USER.md` 中 `回复模式: voice` 时： ``` 1. 生成文本回复内容 2. 调用 scripts/voice-reply.js 生成语音 3. 使用 message 工具发送 opus 文件到飞书 4. 返回 NO_REPLY（语音已发送） ``` ### 脚本调用 ```bash node scripts/voice-reply.js "要说的文本" ``` 成功输出： ``` VOICE_READY:/tmp/openclaw/voice-reply.opus ``` ### 发送语音 ```javascript message({ action: 'send', channel: 'feishu', target: 'user:<open_id>', media: '/tmp/openclaw/voice-reply.opus' }) ``` ## 配置 ### 音色配置 编辑 `config.json` 修改发音人： ```json { "voice": "x4_xiaoyan", "availableVoices": { "x4_xiaoyan": "晓燕 - 女声，温柔", "x4_lingyouyou": "凌悠 - 童声", "x4_yezi": "叶子 - 女声，活泼" } } ``` ### 环境变量 ```bash export XUNFEI_APP_ID="你的应用ID" export XUNFEI_API_KEY="你的API Key" export XUNFEI_API_SECRET="你的API Secret" export XUNFEI_VOICE="x4_lingyouyou" # 可选，覆盖 config.json ``` ## 技术实现 ### 文件结构 ``` xunfei-voice-reply/ ├── SKILL.md # 本文件 ├── config.json # 用户配置（音色、参数） ├── scripts/ │ └── voice-reply.js # 语音生成脚本 ├── lib/ │ ├── tts-core.js # 讯飞 TTS 核心模块 │ └── tts-config.js # 配置读取 └── references/ └── setup.md # 环境配置说明 ``` ### TTS 引擎 使用讯飞 WebSocket TTS API： - **格式**: PCM → ffmpeg → Opus - **音色**: 从 config.json 读取 - **码率**: 24kbps, 24kHz ### 输出路径 音频文件输出到 `/tmp/openclaw/`（OpenClaw 标准临时目录），确保在 message 工具允许的路径内。 ## 错误处理 1. **TTS 超时** → 降级到文字回复 2. **ffmpeg 不可用** → 提示用户安装 3. **发送失败** → 重试或降级 ## 配置持久化 回复模式存储在 `USER.md`: ```markdown - **Notes:** - **回复模式**: text # text 或 voice ``` 每次 session 启动时读取，切换时更新。 ## ⚠️ 必须在 AGENTS.md 中添加 使用此技能前，必须在 AGENTS.md 中添加语音回复流程： ```markdown ## 🎤 Voice Reply (讯飞语音) Check `USER.md` Notes for `回复模式`: - If `回复模式: voice` → use voice for replies - If `回复模式: text` (or not set) → use text ### Voice Reply Flow 1. Generate text response 2. Run: `node ~/.agents/skills/xunfei-voice-reply/scripts/voice-reply.js "text"` 3. Send the Opus file via `message` tool 4. Reply with `NO_REPLY` ### Trigger Words - "用语音" / "语音回复" / "切换语音模式" ```

path: config.json
content: {
  "voice": "x4_xiaoyan",
  "availableVoices": {
    "x4_xiaoyan": "讯飞晓燕",
    "x4_yezi": "讯飞小露",
    "aisjiuxu": "讯飞许久",
    "aisjinger": "讯飞小婧",
    "aisbabyxu": "讯飞许小宝"
  },
  "audio": {
    "aue": "raw",
    "auf": "audio/L16;rate=16000",
    "tte": "UTF8",
    "outputBitrate": "24k",
    "outputSampleRate": 24000
  },
  "timeout": 60000
}


path: lib/tts-config.js
content: /**
 * TTS 配置模块
 * 优先级：环境变量 > config.json > 默认值
 */
const fs = require('fs');
const path = require('path');

const skillDir = path.dirname(__dirname);
const configPath = path.join(skillDir, 'config.json');

// 默认配置
const defaultConfig = {
  voice: 'x4_xiaoyan',
  audio: {
    aue: 'raw',
    auf: 'audio/L16;rate=16000',
    tte: 'UTF8',
    outputBitrate: '24k',
    outputSampleRate: 24000
  },
  timeout: 60000
};

// 读取技能包配置
let skillConfig = {};
try {
  if (fs.existsSync(configPath)) {
    skillConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
  }
} catch (e) {
  console.error('Failed to load config.json:', e.message);
}

module.exports = {
  // 讯飞 TTS 配置
  xunfei: {
    appId: process.env.XUNFEI_APP_ID,
    [REDACTED_SECRET],
    apiSecret: process.env.XUNFEI_API_SECRET,
    host: 'tts-api.xfyun.cn',
    path: '/v2/tts',

    // 发音人：环境变量 > config.json > 默认值
    defaultVoice: process.env.XUNFEI_VOICE || skillConfig.voice || defaultConfig.voice,

    // 音频参数
    audio: skillConfig.audio || defaultConfig.audio,

    // 超时
    timeout: skillConfig.timeout || defaultConfig.timeout
  },

  // 可用音色列表
  availableVoices: skillConfig.availableVoices || {},

  // 输出目录 - 使用 OpenClaw 标准临时目录
  outputDir: '/tmp/openclaw'
};


path: lib/tts-core.js
content: /**
 * 讯飞 TTS 核心模块
 */
const crypto = require('crypto');
const fs = require('fs');
const ws = require('ws');
const config = require('./tts-config');

class XunfeiTTS {
  constructor(options = {}) {
    this.appId = options.appId || config.xunfei.appId;
    this.[REDACTED_SECRET] || config.xunfei.apiKey;
    this.apiSecret = options.apiSecret || config.xunfei.apiSecret;
    this.voice = options.voice || config.xunfei.defaultVoice;
    this.timeout = options.timeout || config.xunfei.timeout;
    this.audioConfig = config.xunfei.audio;
  }

  async synthesize(text, outputPath) {
    return new Promise((resolve, reject) => {
      const timeoutId = setTimeout(() => {
        this.wsClient?.close();
        reject(new Error('TTS timeout'));
      }, this.timeout);

      const host = config.xunfei.host;
      const path = config.xunfei.path;
      const dateStr = new Date().toUTCString();

      const signatureOrigin = `host: ${host}\ndate: ${dateStr}\nGET ${path} HTTP/1.1`;
      const signature = crypto.createHmac('sha256', this.apiSecret)
        .update(signatureOrigin, 'utf8')
        .digest('base64');

      const authorizationOrigin = `[REDACTED_SECRET]", algorithm="hmac-sha256", headers="host date request-line", signature="${signature}"`;
      const authorization = Buffer.from(authorizationOrigin, 'utf8').toString('base64');

      const wsUrl = `wss://${host}${path}?authorization=${encodeURIComponent(authorization)}&date=${encodeURIComponent(dateStr)}&host=${host}`;

      this.wsClient = new ws.WebSocket(wsUrl, {
        headers: { 'Sec-WebSocket-Protocol': 'chat' }
      });

      const audioBuffer = [];

      this.wsClient.on('open', () => {
        this.wsClient.send(JSON.stringify({
          common: { app_id: this.appId },
          business: {
            aue: this.audioConfig.aue,
            auf: this.audioConfig.auf,
            vcn: this.voice,
            tte: this.audioConfig.tte
          },
          data: {
            text: Buffer.from(text).toString('base64'),
            status: 2
          }
        }));
      });

      this.wsClient.on('message', (data) => {
        const res = JSON.parse(data.toString());

        if (res.code !== 0) {
          clearTimeout(timeoutId);
          this.wsClient.close();
          reject(new Error(`TTS error: ${res.desc || res.message}`));
          return;
        }

        if (res.data?.audio) {
          audioBuffer.push(Buffer.from(res.data.audio, 'base64'));
        }

        if (res.data?.status === 2) {
          clearTimeout(timeoutId);
          const pcmAudio = Buffer.concat(audioBuffer);

          if (outputPath) {
            const pcmPath = outputPath.replace(/\.(mp3|opus)$/, '.pcm');
            fs.writeFileSync(pcmPath, pcmAudio);

            const { execSync } = require('child_process');

            if (outputPath.endsWith('.mp3')) {
              execSync(`ffmpeg -y -f s16le -ar 16000 -ac 1 -i ${pcmPath} -ar ${this.audioConfig.outputSampleRate} -ac 1 ${outputPath} 2>/dev/null`);
            } else if (outputPath.endsWith('.opus')) {
              execSync(`ffmpeg -y -f s16le -ar 16000 -ac 1 -i ${pcmPath} -c:a libopus -b:a ${this.audioConfig.outputBitrate} -ar ${this.audioConfig.outputSampleRate} -ac 1 ${outputPath} 2>/dev/null`);
            }

            fs.unlinkSync(pcmPath);
          }

          this.wsClient.close();
          resolve(pcmAudio);
        }
      });

      this.wsClient.on('error', (err) => {
        clearTimeout(timeoutId);
        this.wsClient?.close();
        reject(err);
      });
    });
  }
}

module.exports = { XunfeiTTS, config };


path: references/setup.md
content: # 环境配置说明

## 前置要求

### 1. 讯飞 TTS 账号

访问 [讯飞开放平台](https://www.xfyun.cn/) 注册并创建语音合成应用。

### 2. 环境变量

```bash
export XUNFEI_APP_ID="你的应用ID"
export XUNFEI_API_KEY="你的API Key"
export XUNFEI_API_SECRET="你的API Secret"
```

建议在启动脚本中设置。

### 3. ffmpeg

```bash
# Ubuntu/Debian
sudo apt install ffmpeg

# macOS
brew install ffmpeg
```

### 4. Node.js 依赖

```bash
npm install ws
```

## 音色配置

编辑技能包内的 `config.json`：

```json
{
  "voice": "x4_lingyouyou",
  "availableVoices": {
    "x4_xiaoyan": "讯飞晓燕",
    "x4_yezi": "讯飞小露",
    "aisjiuxu": "讯飞许久",
    "aisjinger": "讯飞小婧",
    "aisbabyxu": "讯飞许小宝"
  },
  "audio": {
    "outputBitrate": "24k",
    "outputSampleRate": 24000
  }
}
```

### 可用音色(免费角色)

| 音色ID | 名称 | 特点 |
|--------|------|------|
| x4_xiaoyan | 晓燕 | 成熟女声 |
| x4_yezi | 小露 | 亲切女声 |
| aisjiuxu | 许久 | 成熟男声 |
| aisjinger | 小婧 | 成熟女声 |
| aisbabyxu | 许小宝 | 童声 |

更多音色见 [讯飞语音合成文档](https://www.xfyun.cn/doc/tts/online_tts/API.html)。

## 集成到 AGENTS.md

```markdown
## Voice Reply

Check `USER.md` Notes for `回复模式`:
- If `回复模式: voice` → use voice for replies
- If `回复模式: text` (or not set) → use text

### Voice Reply Flow
1. Generate text response
2. Run: `node skills/xunfei-voice-reply/scripts/voice-reply.js "text"`
3. Send the Opus file via `message` tool
4. Reply with `NO_REPLY`
```

## 配置 USER.md

```markdown
- **Notes:**
  - **回复模式**: text  # text 或 voice
```

## 配置优先级

1. 环境变量 `XUNFEI_VOICE` - 最高优先级
2. 技能包内 `config.json`
3. 默认值 `x4_xiaoyan`

## 输出路径

音频文件统一输出到 `/tmp/openclaw/voice-reply.opus`。

这是 OpenClaw 的标准临时目录，在 message 工具的 `mediaLocalRoots` 允许路径内。

## 故障排除

### TTS 超时

检查网络连接和讯飞 API 状态。可在 `config.json` 中调整 `timeout`（默认 60 秒）。

### 音频格式错误

确保 ffmpeg 支持 libopus：

```bash
ffmpeg -codecs | grep opus
```

### 权限错误

```bash
chmod +x scripts/voice-reply.js
```

### 查看可用音色

```bash
node -e "console.log(JSON.stringify(require('./config.json').availableVoices, null, 2))"
```


path: scripts/voice-reply.js
content: #!/usr/bin/env node
/**
 * 语音回复脚本 - 使用讯飞 TTS 生成语音
 * 用法: node voice-reply.js "要说的文本"
 */
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');

// 动态确定 skill 目录位置
const skillDir = path.dirname(__dirname);
const libDir = path.join(skillDir, 'lib');
const { XunfeiTTS } = require(path.join(libDir, 'tts-core'));
const config = require(path.join(libDir, 'tts-config'));

const text = process.argv[2];
if (!text) {
  console.error('Usage: node voice-reply.js "text"');
  process.exit(1);
}

// 使用配置中的输出目录
const tmpDir = config.outputDir;
if (!fs.existsSync(tmpDir)) {
  fs.mkdirSync(tmpDir, { recursive: true });
}

const mp3Path = path.join(tmpDir, 'voice-reply-temp.mp3');
const opusPath = path.join(tmpDir, 'voice-reply.opus');

async function main() {
  try {
    // 1. 使用讯飞 TTS 生成 MP3
    const tts = new XunfeiTTS();
    await tts.synthesize(text, mp3Path);

    // 2. 转换为 Opus（飞书要求）
    execSync(`ffmpeg -y -i ${mp3Path} -c:a libopus -b:a ${config.xunfei.audio.outputBitrate} -ar ${config.xunfei.audio.outputSampleRate} -ac 1 ${opusPath} 2>/dev/null`, {
      stdio: 'pipe'
    });

    // 3. 输出路径供调用方使用
    console.log(`VOICE_READY:${opusPath}`);

    // 清理临时文件
    if (fs.existsSync(mp3Path)) {
      fs.unlinkSync(mp3Path);
    }

  } catch (err) {
    console.error('VOICE_ERROR:', err.message);
    process.exit(1);
  }
}

main();


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