Commit 06d4382c9b41125f64a1ab45dc5d9c7ee9887cee
1 parent
8ce44711
feat(skill): 声音合成 skill — 文本→TTS→SILK→微信语音,全程手机端
新 skill voice-synth-send:一段文本合成为语音,作为微信语音消息发给联系人。 全程在 Pixel Termux 内跑(Hermes 环境),无需 Mac、无需本地 TTS 模型。 - voice_synth.sh: text → edge-tts(默认,免费云) / 豆包 seed-audio(可选,需额度) → MP3 → ffmpeg 16k mono PCM → silk-wasm SILK V3 → stage 进微信数据目录 → SEND_VOICE_CGI。 引擎 edge|doubao;音色可选;豆包 key 走 DOUBAO_KEY 环境变量(不入库)。 - 澄清 edge-tts 是云 TTS(非本地模型,无需 key/GPU,联网即用);本机 Termux 已具备 python/edge-tts/node/silk-wasm/ffmpeg。 - SKILL.md: 用法/引擎对比/真机验证/坑(TMPDIR、SILK 权限、adb-su 无 DNS 是测试 artifact 非 skill bug)。 真机验证 2026-07-07:edge-tts(6.9s)+豆包 seed-audio(8.2s) 均发火星人成功,多分块上传 完整可播放,发送方本地已发送气泡。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Showing
2 changed files
with
141 additions
and
0 deletions
xhs_lsposed_a16_fix/skill_voice_synth/SKILL.md
0 → 100644
| 1 | +--- | |
| 2 | +name: voice-synth-send | |
| 3 | +description: 文本转语音并作为微信语音消息发送(edge-tts 云合成/豆包可选 → SILK → 微信内部 uploadvoice cgi)。全程在 Pixel 手机 Termux 内运行,无需本地 TTS 模型。 | |
| 4 | +version: 1.0 | |
| 5 | +category: communication | |
| 6 | +--- | |
| 7 | + | |
| 8 | +# 声音合成 + 微信语音发送 | |
| 9 | + | |
| 10 | +把一段文本合成为语音,作为**微信语音消息**发给指定联系人。**全程在手机 Termux 内跑** | |
| 11 | +(Hermes 所在环境),无需 Mac、无需本地 TTS 模型。 | |
| 12 | + | |
| 13 | +``` | |
| 14 | +文本 → TTS(edge-tts 免费云 / 豆包可选)→ MP3 | |
| 15 | + → ffmpeg 16k mono PCM → silk-wasm SILK V3(腾讯 \x02#!SILK_V3 变种) | |
| 16 | + → stage 进微信数据目录 → am broadcast SEND_VOICE_CGI(内部 uploadvoice cgi) | |
| 17 | +``` | |
| 18 | +接收方收到**可播放的语音条**,发送方本地也显示**已发送**气泡。依赖上游 skill | |
| 19 | +[wechat-internal-send](../skill_wechat_send/SKILL.md) 的 `SEND_VOICE_CGI` 链路。 | |
| 20 | + | |
| 21 | +## When to Use | |
| 22 | +- 用户要「发一条语音」「用语音念给某人」「语音播报」 | |
| 23 | +- Agent 主动语音通知 / 播报 | |
| 24 | + | |
| 25 | +## 关键澄清:edge-tts 不是本地模型 | |
| 26 | +edge-tts 是**微软 Edge 的免费云 TTS**(Python 库,把文本发到微软服务器合成、流式取回), | |
| 27 | +**不需要本地模型、不需要 API key、不占 GPU,只需要联网**。所以「手机上没本地 TTS 模型」 | |
| 28 | +不是问题。Hermes 作为 Termux app 有正常网络(它本来就在调 Kimi API),edge-tts 直接可用。 | |
| 29 | + | |
| 30 | +## 前置(本机 Termux 已具备,2026-07-07 核实) | |
| 31 | +- `python3` + `edge-tts` 7.2.8、`ffmpeg`、`node` + `silk-wasm` | |
| 32 | + (`/data/data/com.termux/files/usr/lib/node_modules/silk-wasm`) | |
| 33 | +- root(`su`)——用于把 SILK 放进微信数据目录 + 发广播 | |
| 34 | +- wechathook LSPosed 模块已装、微信在跑(`SEND_VOICE_CGI` 由它处理) | |
| 35 | + | |
| 36 | +## 用法 | |
| 37 | +```bash | |
| 38 | +# 在 Termux 内(Hermes 环境)直接跑: | |
| 39 | +bash voice_synth.sh <wxid> "<要说的话>" [voice] [engine] | |
| 40 | + | |
| 41 | +# 例:edge-tts 男声发给文件传输助手(先用 filehelper 测试) | |
| 42 | +bash voice_synth.sh filehelper "你好,这是一条语音测试。" | |
| 43 | + | |
| 44 | +# 指定音色(女声) | |
| 45 | +bash voice_synth.sh yibooo "下午三点开会,记得带材料。" zh-CN-XiaoxiaoNeural | |
| 46 | + | |
| 47 | +# 豆包引擎(需额度 + 环境变量 DOUBAO_KEY,可发带音乐/音效/多角色的场景音频) | |
| 48 | +DOUBAO_KEY=xxxx bash voice_synth.sh yibooo "<豆包 seed-audio 场景 prompt>" "" doubao | |
| 49 | +``` | |
| 50 | +- `<wxid>`:rcontact.username(`filehelper` / `wxid_xxx` / `yibooo`=火星人)。先用 filehelper 测。 | |
| 51 | +- `[voice]`:edge-tts 音色,默认 `zh-CN-YunxiNeural`(男声);女声 `zh-CN-XiaoxiaoNeural`。 | |
| 52 | +- `[engine]`:`edge`(默认,免费无额度)| `doubao`(seed-audio-1.0,需 `DOUBAO_KEY` + 账号额度)。 | |
| 53 | + | |
| 54 | +## 引擎对比 | |
| 55 | +| 引擎 | 成本 | 特点 | | |
| 56 | +|---|---|---| | |
| 57 | +| **edge-tts**(默认)| 免费,无额度 | 单人 TTS,多语言多音色,联网即用;日常播报首选 | | |
| 58 | +| **豆包 seed-audio-1.0** | 按字数计费(火山账号额度)| 可生成整段带**背景音乐/音效/多角色**的场景音频;额度用完报 `45000030 not granted` | | |
| 59 | + | |
| 60 | +> ⚠️ 额度/授权:豆包报 `code 45000030 [resource_id=volc.service_type.10074] requested resource not granted` | |
| 61 | +> = 账号未开通「音频生成/SeedAudio」或**字数额度用完**。控制台开通/充值后同 key 即可用。 | |
| 62 | + | |
| 63 | +## 真机验证(2026-07-07) | |
| 64 | +- edge-tts(6.9s)+ 豆包 seed-audio(8.2s)均端到端发火星人(yibooo)成功,多分块(6000 字节/片) | |
| 65 | + 上传完整、可播放;发送方本地显示已发送。SILK 头 `\x02#!SILK_V3` 与微信自录字节级一致。 | |
| 66 | + | |
| 67 | +## 坑 | |
| 68 | +- **edge-tts 要联网**:Hermes 原生跑没问题;但 `adb→su→termux` 起的进程拿不到 per-UID DNS | |
| 69 | + (`http=000`),别用那种方式测网络部分——在 Termux 里直接跑或让 Hermes 调。 | |
| 70 | +- **SILK 必须 WeChat 进程可读**:脚本已 stage 进 `/data/data/com.tencent.mm/files/` + | |
| 71 | + chown 微信 uid + restorecon;`/sdcard` 会 EACCES。 | |
| 72 | +- **TMPDIR**:脚本强制 `$PREFIX/tmp`(root/adb 环境会把 TMPDIR 顶成不可写的 /data/local/tmp)。 | |
| 73 | +- **不要自己实现 SILK 编码**:用 silk-wasm(腾讯变种),pysilk 等未必字节兼容。 | |
| 74 | + | |
| 75 | +## 相关 | |
| 76 | +- `voice_synth.sh` — 本 skill 脚本 | |
| 77 | +- `../skill_wechat_send/SKILL.md` — 底层 SEND_VOICE_CGI 发送链 | |
| 78 | +- `../WECHAT_SEND_API.md` §2 — 语音全合成逆向(uploadvoice cgi + 派发) | |
| 79 | +- memory `wechat-voice-send-internals.md` | ... | ... |
xhs_lsposed_a16_fix/skill_voice_synth/voice_synth.sh
0 → 100755
| 1 | +#!/data/data/com.termux/files/usr/bin/bash | |
| 2 | +# voice_synth.sh — synthesize speech and send it as a WeChat voice message. | |
| 3 | +# Runs FULLY ON-DEVICE in Termux (where Hermes lives): no Mac, no local TTS model. | |
| 4 | +# | |
| 5 | +# text → TTS (edge-tts cloud, free / or Doubao) → MP3 | |
| 6 | +# → ffmpeg 16k mono PCM → silk-wasm SILK V3 (Tencent \x02#!SILK_V3 variant) | |
| 7 | +# → stage into WeChat's data dir (uid + SELinux ctx) | |
| 8 | +# → am broadcast SEND_VOICE_CGI (internal uploadvoice cgi, our synthetic voice send) | |
| 9 | +# | |
| 10 | +# The recipient gets a playable voice AND the sender sees a sent bubble. | |
| 11 | +# | |
| 12 | +# Usage: | |
| 13 | +# voice_synth.sh <wxid> "<text>" [voice] [engine] | |
| 14 | +# <wxid> talker id as in rcontact.username (e.g. filehelper, wxid_xxx, yibooo) | |
| 15 | +# <text> what to say (UTF-8; for Doubao you can embed the rich scene prompt) | |
| 16 | +# [voice] edge-tts voice name (default zh-CN-YunxiNeural male; e.g. zh-CN-XiaoxiaoNeural female) | |
| 17 | +# [engine] edge (default, free cloud, no quota) | doubao (needs DOUBAO_KEY + quota) | |
| 18 | +# | |
| 19 | +# Test with filehelper first. edge-tts needs internet (it's a cloud service, not a local model). | |
| 20 | +set -e | |
| 21 | + | |
| 22 | +WXID="$1"; TEXT="$2"; VOICE="${3:-zh-CN-YunxiNeural}"; ENGINE="${4:-edge}" | |
| 23 | +[ -z "$WXID" ] || [ -z "$TEXT" ] && { echo "usage: voice_synth.sh <wxid> \"<text>\" [voice] [engine]"; exit 1; } | |
| 24 | + | |
| 25 | +PREFIX=/data/data/com.termux/files/usr | |
| 26 | +SILK_WASM="$PREFIX/lib/node_modules/silk-wasm" | |
| 27 | +export TMPDIR="$PREFIX/tmp"; mkdir -p "$TMPDIR" # Termux tmp (root/adb env may force /data/local/tmp) | |
| 28 | +WORK=$(mktemp -d "$TMPDIR/voicesynth.XXXXXX") | |
| 29 | +trap 'rm -rf "$WORK"' EXIT | |
| 30 | +MP3="$WORK/tts.mp3"; PCM="$WORK/tts.pcm"; SILK="$WORK/tts.silk" | |
| 31 | + | |
| 32 | +# ── 1. text → MP3 ──────────────────────────────────────────────────────────── | |
| 33 | +case "$ENGINE" in | |
| 34 | + edge) | |
| 35 | + echo "[1/3] edge-tts ($VOICE) → MP3" | |
| 36 | + edge-tts --voice "$VOICE" --text "$TEXT" --write-media "$MP3" >/dev/null | |
| 37 | + ;; | |
| 38 | + doubao) | |
| 39 | + : "${DOUBAO_KEY:?doubao engine needs DOUBAO_KEY env var}" | |
| 40 | + echo "[1/3] Doubao seed-audio-1.0 → MP3" | |
| 41 | + PROMPT=$(python3 -c "import json,sys;print(json.dumps(sys.argv[1]))" "$TEXT") | |
| 42 | + curl -s --max-time 120 -X POST 'https://openspeech.bytedance.com/api/v3/tts/create' \ | |
| 43 | + -H 'Content-Type: application/json' -H "X-Api-Key: $DOUBAO_KEY" \ | |
| 44 | + --data-raw "{\"model\":\"seed-audio-1.0\",\"text_prompt\":$PROMPT,\"audio_config\":{\"format\":\"mp3\",\"sample_rate\":48000},\"watermark\":{}}" \ | |
| 45 | + | python3 -c "import json,sys,base64; d=json.load(sys.stdin); sys.exit('Doubao error: '+str(d)) if 'audio' not in d else open('$MP3','wb').write(base64.b64decode(d['audio']))" | |
| 46 | + ;; | |
| 47 | + *) echo "unknown engine: $ENGINE (use edge|doubao)"; exit 1 ;; | |
| 48 | +esac | |
| 49 | + | |
| 50 | +# ── 2. MP3 → 16k mono PCM → SILK V3 ────────────────────────────────────────── | |
| 51 | +echo "[2/3] MP3 → 16k mono PCM → SILK V3" | |
| 52 | +ffmpeg -y -i "$MP3" -ar 16000 -ac 1 -f s16le "$PCM" -loglevel error | |
| 53 | +DUR=$(node -e "const s=require('$SILK_WASM'),fs=require('fs');s.encode(fs.readFileSync('$PCM'),16000).then(r=>{fs.writeFileSync('$SILK',r.data);process.stdout.write(String(Math.round(r.duration)));})") | |
| 54 | +[ -s "$SILK" ] || { echo "SILK encode failed"; exit 1; } | |
| 55 | + | |
| 56 | +# ── 3. stage into WeChat data dir + fire SEND_VOICE_CGI (root) ──────────────── | |
| 57 | +echo "[3/3] stage SILK + SEND_VOICE_CGI → $WXID (${DUR}ms)" | |
| 58 | +WX_UID=$(su -c "stat -c %U /data/user/0/com.tencent.mm" | tr -d '\r') | |
| 59 | +STAGE=/data/data/com.tencent.mm/files/hook_voice_cgi.silk | |
| 60 | +su -c "cp '$SILK' '$STAGE' && chown $WX_UID:$WX_UID '$STAGE' && chmod 600 '$STAGE'; restorecon '$STAGE' 2>/dev/null; \ | |
| 61 | + am broadcast -a com.hermes.wechat.SEND_VOICE_CGI --es to '$WXID' --es src_path '$STAGE' --ei duration $DUR" | |
| 62 | +echo "done: sent ${DUR}ms voice to $WXID via $ENGINE." | ... | ... |