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
This commit is contained in:
@@ -13,10 +13,12 @@
|
||||
.venv/bin/python3 transcribe_chat.py /tmp/chat.json /tmp/chat_transcribed.json
|
||||
|
||||
行为说明:
|
||||
- 使用 OpenAI Whisper (CPU,单线程) 对每条语音消息转录。
|
||||
- 后端由 config.json 中 transcription_backend 字段控制 (local/openai),
|
||||
与 MCP transcribe_voice 工具共享配置。详见 README "语音转录隐私" 章节。
|
||||
- 默认 local: 使用本地 Whisper (CPU,单线程),首次运行下载 ~145 MB 权重。
|
||||
- 切到 openai: 语音上传至 OpenAI 服务器转录 (~$0.006/分钟)。
|
||||
- 幂等: 已有 "transcription" 字段的消息会被跳过,因此崩溃/中断后可安全重跑。
|
||||
- 崩溃安全: 每处理完一条即整体重写输出 JSON,进程中断最多丢失当前一条。
|
||||
- 首次运行会下载 Whisper 模型 (~145 MB) 并缓存。
|
||||
|
||||
需要 WeChat DB 仍然在线/已解密 —— 语音 blob 是从 DB 现场按 local_id 读取的,
|
||||
不从 JSON 读。
|
||||
@@ -29,7 +31,7 @@ from datetime import datetime
|
||||
import mcp_server
|
||||
|
||||
|
||||
def _transcribe_local_id(username, local_id):
|
||||
def _transcribe_local_id(username, local_id, backend):
|
||||
row = mcp_server._fetch_voice_row(username, local_id)
|
||||
if row is None:
|
||||
return "[not found]"
|
||||
@@ -41,9 +43,8 @@ def _transcribe_local_id(username, local_id):
|
||||
return f"[decode error: {e}]"
|
||||
|
||||
try:
|
||||
model = mcp_server._get_whisper_model()
|
||||
result = model.transcribe(wav_path)
|
||||
return result.get("text", "").strip()
|
||||
result = mcp_server._transcribe(wav_path, backend)
|
||||
return result["text"]
|
||||
except Exception as e:
|
||||
return f"[transcribe error: {e}]"
|
||||
|
||||
@@ -70,17 +71,22 @@ def transcribe_export(input_path, output_path):
|
||||
print("No voice messages to transcribe.")
|
||||
return
|
||||
|
||||
backend = mcp_server._resolve_active_backend()
|
||||
print(f"Found {total} voice messages to transcribe.")
|
||||
print("Loading Whisper model (first run downloads ~145MB)...")
|
||||
mcp_server._get_whisper_model()
|
||||
print("Model ready.\n")
|
||||
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)
|
||||
result = _transcribe_local_id(username, local_id, backend)
|
||||
msg["transcription"] = result
|
||||
print(repr(result[:60]) if result else '""')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user