voice_synth.sh
3.82 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
#!/data/data/com.termux/files/usr/bin/bash
# voice_synth.sh — synthesize speech and send it as a WeChat voice message.
# Runs FULLY ON-DEVICE in Termux (where Hermes lives): no Mac, no local TTS model.
#
# text → TTS (edge-tts cloud, free / or Doubao) → MP3
# → ffmpeg 16k mono PCM → silk-wasm SILK V3 (Tencent \x02#!SILK_V3 variant)
# → stage into WeChat's data dir (uid + SELinux ctx)
# → am broadcast SEND_VOICE_CGI (internal uploadvoice cgi, our synthetic voice send)
#
# The recipient gets a playable voice AND the sender sees a sent bubble.
#
# Usage:
# voice_synth.sh <wxid> "<text>" [voice] [engine]
# <wxid> talker id as in rcontact.username (e.g. filehelper, wxid_xxx, yibooo)
# <text> what to say (UTF-8; for Doubao you can embed the rich scene prompt)
# [voice] edge-tts voice name (default zh-CN-YunxiNeural male; e.g. zh-CN-XiaoxiaoNeural female)
# [engine] edge (default, free cloud, no quota) | doubao (needs DOUBAO_KEY + quota)
#
# Test with filehelper first. edge-tts needs internet (it's a cloud service, not a local model).
set -e
WXID="$1"; TEXT="$2"; VOICE="${3:-zh-CN-YunxiNeural}"; ENGINE="${4:-edge}"
[ -z "$WXID" ] || [ -z "$TEXT" ] && { echo "usage: voice_synth.sh <wxid> \"<text>\" [voice] [engine]"; exit 1; }
PREFIX=/data/data/com.termux/files/usr
SILK_WASM="$PREFIX/lib/node_modules/silk-wasm"
export TMPDIR="$PREFIX/tmp"; mkdir -p "$TMPDIR" # Termux tmp (root/adb env may force /data/local/tmp)
WORK=$(mktemp -d "$TMPDIR/voicesynth.XXXXXX")
trap 'rm -rf "$WORK"' EXIT
MP3="$WORK/tts.mp3"; PCM="$WORK/tts.pcm"; SILK="$WORK/tts.silk"
# ── 1. text → MP3 ────────────────────────────────────────────────────────────
case "$ENGINE" in
edge)
echo "[1/3] edge-tts ($VOICE) → MP3"
edge-tts --voice "$VOICE" --text "$TEXT" --write-media "$MP3" >/dev/null
;;
doubao)
: "${DOUBAO_KEY:?doubao engine needs DOUBAO_KEY env var}"
echo "[1/3] Doubao seed-audio-1.0 → MP3"
PROMPT=$(python3 -c "import json,sys;print(json.dumps(sys.argv[1]))" "$TEXT")
curl -s --max-time 120 -X POST 'https://openspeech.bytedance.com/api/v3/tts/create' \
-H 'Content-Type: application/json' -H "X-Api-Key: $DOUBAO_KEY" \
--data-raw "{\"model\":\"seed-audio-1.0\",\"text_prompt\":$PROMPT,\"audio_config\":{\"format\":\"mp3\",\"sample_rate\":48000},\"watermark\":{}}" \
| 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']))"
;;
*) echo "unknown engine: $ENGINE (use edge|doubao)"; exit 1 ;;
esac
# ── 2. MP3 → 16k mono PCM → SILK V3 ──────────────────────────────────────────
echo "[2/3] MP3 → 16k mono PCM → SILK V3"
ffmpeg -y -i "$MP3" -ar 16000 -ac 1 -f s16le "$PCM" -loglevel error
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)));})")
[ -s "$SILK" ] || { echo "SILK encode failed"; exit 1; }
# ── 3. stage into WeChat data dir + fire SEND_VOICE_CGI (root) ────────────────
echo "[3/3] stage SILK + SEND_VOICE_CGI → $WXID (${DUR}ms)"
WX_UID=$(su -c "stat -c %U /data/user/0/com.tencent.mm" | tr -d '\r')
STAGE=/data/data/com.tencent.mm/files/hook_voice_cgi.silk
su -c "cp '$SILK' '$STAGE' && chown $WX_UID:$WX_UID '$STAGE' && chmod 600 '$STAGE'; restorecon '$STAGE' 2>/dev/null; \
am broadcast -a com.hermes.wechat.SEND_VOICE_CGI --es to '$WXID' --es src_path '$STAGE' --ei duration $DUR"
echo "done: sent ${DUR}ms voice to $WXID via $ENGINE."