Files
zWorkFlow/transcribe_chat.py
btc-z 66eddaff0e feat: transcribe_voice 新增 OpenAI Whisper API 后端 (#66)
默认 local,零行为变化。opt-in 双因素:transcription_backend=openai
且 openai_api_key 都齐才生效;任一缺失静默回退 local + stderr 一行警告。
首次进入云路径会 stderr 警告"语音将上传至 OpenAI 服务器"。

新增 config.json 字段:
- transcription_backend: "local" (默认) | "openai"
- local_whisper_model: "base" (替换 mcp_server.py 里硬编码 DEFAULT_WHISPER_MODEL)
- openai_api_key: "" (默认空;openai 包为 optional,按需 pip install)

关键技术选择:
- _transcribe(wav, backend) 单一 if/else 分发,不引入插件/工厂层
  (Rule of Three —— 只有一个云后端时不值得抽象)
- 文件 > 25MB 在 OpenAI() 实例化之前提前拒绝,避免无谓上传
- 错误分类清晰: 缺 key / 缺 openai 包 / 401 / 429 / APIError 各自的提示
- PR #58 缓存 schema 自然扩展: 条目加 backend 字段,命中需 backend+model_size 都匹配
- 旧条目缺 backend 字段视为 "local",向前兼容 PR #58 已落盘的所有数据
- transcribe_chat.py 批量 CLI 与 MCP 工具共享同一份配置,保持一致

新增 2 个测试 (tests/test_openai_backend.py),只覆盖回归风险最高的两条:
- 文件 > 25MB 必须在 SDK 实例化前拒绝(隐私契约的防线)
- backend 不匹配的旧条目不命中(避免切后端时返回错后端结果)

其余路径要么琐碎(默认值读取)、要么坏掉时声音很大(SDK 错误、ImportError),
要么已被 PR #58 现有测试隐式覆盖(缺 backend 字段的旧条目),不再单独写测试。

顺手把 README 里 PR #53 漏掉的 voice 三件套(get_voice_messages /
decode_voice / transcribe_voice)补进 MCP 工具表,并新增"⚠️ 语音转录隐私"
章节说清数据流向、成本(约 \$0.006/分钟)、25MB 上限、回退行为。

Closes ylytdeng/wechat-decrypt#59
2026-05-01 13:56:32 +08:00

109 lines
3.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
为聊天导出 JSON 中的语音消息补齐转录文本。
用法:
.venv/bin/python3 transcribe_chat.py <input.json> [output.json]
参数:
<input.json> 由 export_chat.py 产出的 JSON。
[output.json] 可选输出路径,默认 "<input>_transcribed.json"
完整流程示例:
.venv/bin/python3 export_chat.py <chat_name> /tmp/chat.json
.venv/bin/python3 transcribe_chat.py /tmp/chat.json /tmp/chat_transcribed.json
行为说明:
- 后端由 config.json 中 transcription_backend 字段控制 (local/openai)
与 MCP transcribe_voice 工具共享配置。详见 README "语音转录隐私" 章节。
- 默认 local: 使用本地 Whisper (CPU单线程),首次运行下载 ~145 MB 权重。
- 切到 openai: 语音上传至 OpenAI 服务器转录 (~$0.006/分钟)。
- 幂等: 已有 "transcription" 字段的消息会被跳过,因此崩溃/中断后可安全重跑。
- 崩溃安全: 每处理完一条即整体重写输出 JSON进程中断最多丢失当前一条。
需要 WeChat DB 仍然在线/已解密 —— 语音 blob 是从 DB 现场按 local_id 读取的,
不从 JSON 读。
"""
import json
import os
import sys
from datetime import datetime
import mcp_server
def _transcribe_local_id(username, local_id, backend):
row = mcp_server._fetch_voice_row(username, local_id)
if row is None:
return "[not found]"
voice_data, create_time = row
try:
wav_path, _ = mcp_server._silk_to_wav(voice_data, create_time, username, local_id)
except Exception as e:
return f"[decode error: {e}]"
try:
result = mcp_server._transcribe(wav_path, backend)
return result["text"]
except Exception as e:
return f"[transcribe error: {e}]"
def transcribe_export(input_path, output_path):
with open(input_path, encoding="utf-8") as f:
data = json.load(f)
# 优先使用导出 JSON 中已记录的 username避免重新模糊匹配导致同名联系人漂移。
username = data.get("username")
chat_name = data.get("chat", "")
if not username:
username = mcp_server.resolve_username(chat_name)
if not username:
print(f"Could not resolve username for: {chat_name}")
sys.exit(1)
messages = data["messages"]
# Compact format: type is absent for text; transcription is only present when filled.
pending = [m for m in messages if m.get("type") == "voice" and not m.get("transcription")]
total = len(pending)
if total == 0:
print("No voice messages to transcribe.")
return
backend = mcp_server._resolve_active_backend()
print(f"Found {total} voice messages to transcribe.")
print(f"Backend: {backend}")
if backend == "local":
print("Loading Whisper model (first run downloads ~145MB)...")
mcp_server._get_whisper_model()
print("Model ready.\n")
else:
print("")
for i, msg in enumerate(pending, 1):
local_id = msg["local_id"]
ts = msg["timestamp"]
ts_str = datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S") if isinstance(ts, (int, float)) else ts
print(f"[{i}/{total}] local_id={local_id} ({ts_str}) ... ", end="", flush=True)
result = _transcribe_local_id(username, local_id, backend)
msg["transcription"] = result
print(repr(result[:60]) if result else '""')
# Save after each transcription so progress isn't lost on crash
with open(output_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
print(f"\nDone. Written to {output_path}")
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python3 transcribe_chat.py <input.json> [output.json]")
sys.exit(1)
inp = sys.argv[1]
base, ext = os.path.splitext(inp)
out = sys.argv[2] if len(sys.argv) > 2 else f"{base}_transcribed{ext}"
transcribe_export(inp, out)