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:
btc-z
2026-05-01 01:56:32 -04:00
committed by GitHub
parent 989badd14f
commit 66eddaff0e
6 changed files with 306 additions and 32 deletions

View File

@@ -1971,28 +1971,141 @@ def _save_voice_transcription_cache():
pass
DEFAULT_WHISPER_MODEL = "base"
# ============ 语音转录后端 ============
#
# 默认 local: 完全保留原有行为CPU 上跑本地 Whisper。
# opt-in openai: 需要 transcription_backend="openai" 且 openai_api_key 都齐
# 才会上云;任一缺失静默回退 local + stderr 一行警告(用户感知到误配置但不阻塞)。
# 详见 README "语音转录隐私" 章节。
TRANSCRIPTION_BACKEND = _cfg.get("transcription_backend", "local")
LOCAL_WHISPER_MODEL = _cfg.get("local_whisper_model", "base")
OPENAI_API_KEY = _cfg.get("openai_api_key", "")
OPENAI_WHISPER_MODEL = "whisper-1" # OpenAI 当前唯一型号
OPENAI_AUDIO_LIMIT_BYTES = 25 * 1024 * 1024 # OpenAI 25MB 上限
_whisper_model = None
_openai_client = None
_openai_warning_emitted = False
_fallback_warning_emitted = False
def _get_whisper_model(model_size=DEFAULT_WHISPER_MODEL):
def _resolve_active_backend():
"""两因素 opt-inopenai 需要 flag + key 都齐才生效。"""
global _fallback_warning_emitted
if TRANSCRIPTION_BACKEND == "openai":
if not OPENAI_API_KEY:
if not _fallback_warning_emitted:
print(
"[whisper] transcription_backend=openai 但未配置 openai_api_key"
"回退到本地模型",
file=sys.stderr, flush=True,
)
_fallback_warning_emitted = True
return "local"
return "openai"
return "local"
def _cache_signature():
"""当前生效后端 + 模型,用作缓存命中判定 + 落盘字段。"""
backend = _resolve_active_backend()
if backend == "openai":
return {"backend": "openai", "model_size": OPENAI_WHISPER_MODEL}
return {"backend": "local", "model_size": LOCAL_WHISPER_MODEL}
def _get_whisper_model(model_size=None):
global _whisper_model
if model_size is None:
model_size = LOCAL_WHISPER_MODEL
if _whisper_model is None:
import whisper
_whisper_model = whisper.load_model(model_size)
return _whisper_model
def _transcribe_local(wav_path):
model = _get_whisper_model()
result = model.transcribe(wav_path)
return {
"language": result.get("language", "unknown"),
"text": result.get("text", "").strip(),
}
def _transcribe_openai(wav_path):
"""通过 OpenAI Whisper API 转录。失败抛 RuntimeError调用方负责面向用户的提示。"""
global _openai_client, _openai_warning_emitted
# 尺寸预检:放在 SDK 导入和实例化之前,确保超限文件绝不上传
size = os.path.getsize(wav_path)
if size > OPENAI_AUDIO_LIMIT_BYTES:
raise RuntimeError(
f"音频 {size / 1024 / 1024:.1f}MB 超过 OpenAI 25MB 上限,"
"提前拒绝以避免无谓上传"
)
try:
from openai import OpenAI
from openai import AuthenticationError, RateLimitError, APIError
except ImportError:
raise RuntimeError("缺少依赖: pip install openai")
if not _openai_warning_emitted:
print(
"[whisper] 已启用 OpenAI Whisper API"
"语音将上传至 OpenAI 服务器进行转录",
file=sys.stderr, flush=True,
)
_openai_warning_emitted = True
if _openai_client is None:
_openai_client = OpenAI(api_key=OPENAI_API_KEY)
try:
with open(wav_path, "rb") as f:
result = _openai_client.audio.transcriptions.create(
model=OPENAI_WHISPER_MODEL,
file=f,
response_format="verbose_json",
)
except AuthenticationError:
raise RuntimeError("OpenAI 鉴权失败 (401):检查 openai_api_key")
except RateLimitError:
raise RuntimeError("OpenAI 限流 (429):稍后重试")
except APIError as e:
raise RuntimeError(f"OpenAI API 错误: {e}")
return {
"language": getattr(result, "language", "unknown"),
"text": (getattr(result, "text", "") or "").strip(),
}
def _transcribe(wav_path, backend):
if backend == "openai":
return _transcribe_openai(wav_path)
return _transcribe_local(wav_path)
@mcp.tool()
def transcribe_voice(chat_name: str, local_id: int) -> str:
"""将微信语音消息转录为文字(自动检测语言,保留原语言)。
首次转录会先解码 SILK 语音为 WAV再用 Whisper 转录;结果缓存到
voice_transcriptions.json重复调用直接返回缓存跳过 SILK 解码
和 Whisper 推理)。若 Whisper 默认模型升级(如 base → small
旧条目自动视为失效并重新转录。首次运行会下载 Whisper 模型(约 145MB
和 Whisper 推理)。后端切换或本地模型升级(如 base → small
旧条目自动视为失效并重新转录。首次运行本地模型会下载约 145MB 权重
依赖: pip install silk-python openai-whisper (silk-python 的 import 名为 pysilk)
后端由 config.json 中 transcription_backend 字段控制local/openai
详见 README "语音转录隐私" 章节。
依赖:
- 本地后端: pip install silk-python openai-whisper
(silk-python 的 import 名为 pysilk)
- OpenAI 后端: pip install silk-python openai
Args:
chat_name: 聊天对象的名字、备注名或wxid
@@ -2002,15 +2115,20 @@ def transcribe_voice(chat_name: str, local_id: int) -> str:
if not username:
return f"找不到聊天对象: {chat_name}"
sig = _cache_signature()
cache_key = _voice_transcription_cache_key(username, local_id)
cache = _load_voice_transcription_cache()
entry = cache.get(cache_key)
# 命中要求 backend + model_size 都匹配。
# 旧条目 (PR #58 schema) 缺 backend 字段,回填默认 "local" 保持向前兼容
# —— 那时唯一存在的后端就是 local语义上等价。
if (
isinstance(entry, dict)
and "text" in entry
and entry.get("model_size") == DEFAULT_WHISPER_MODEL
and entry.get("backend", "local") == sig["backend"]
and entry.get("model_size") == sig["model_size"]
):
# 命中缓存:跳过 DB 查询、SILK 解码、Whisper 推理
# 命中缓存:跳过 DB 查询、SILK 解码、转录
# 条目里存了 create_time即使源 DB 中消息已被清理仍能返回历史转录。
lang = entry.get("language", "unknown")
cached_ts = entry.get("create_time")
@@ -2020,11 +2138,13 @@ def transcribe_voice(chat_name: str, local_id: int) -> str:
time_label = "-"
return f"[{time_label}] ({lang})\n{entry['text']}"
# 未命中:只有这条路径才需要 whisper / pysilk。
try:
import whisper # noqa: F401
except ImportError:
return "缺少依赖: pip install openai-whisper"
# 未命中:本地后端才需要 whisper 包,云后端在 _transcribe_openai 内单独检查
if sig["backend"] == "local":
try:
import whisper # noqa: F401
except ImportError:
return "缺少依赖: pip install openai-whisper"
# SILK 解码两条路径都需要
try:
import pysilk # noqa: F401
except ImportError:
@@ -2037,18 +2157,21 @@ def transcribe_voice(chat_name: str, local_id: int) -> str:
voice_data, create_time = row
wav_path, _ = _silk_to_wav(voice_data, create_time, username, local_id)
model = _get_whisper_model()
result = model.transcribe(wav_path)
lang = result.get("language", "unknown")
text = result.get("text", "").strip()
try:
result = _transcribe(wav_path, sig["backend"])
except RuntimeError as e:
return str(e)
text = result["text"]
lang = result["language"]
# 写缓存:即使 text 为空也缓存Whisper 偶尔对静音/极短片段返回空),
# 配合 model_size 字段,升级模型后会自动重转,避免永久钉死空结果
# 配合 backend + model_size 字段,切换后端或升级模型后会自动重转。
cache[cache_key] = {
"text": text,
"language": lang,
"create_time": int(create_time),
"model_size": DEFAULT_WHISPER_MODEL,
"backend": sig["backend"],
"model_size": sig["model_size"],
}
_save_voice_transcription_cache()