From fe5cc633fff6eb7dbf0eeb38e731cb96ba4a65ec Mon Sep 17 00:00:00 2001 From: Davy <95214375+thedavidweng@users.noreply.github.com> Date: Mon, 11 May 2026 20:42:53 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20transcribe=5Fvoice=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=20whisper.cpp=20=E5=90=8E=E7=AB=AF=EF=BC=88macOS=20Metal=20GPU?= =?UTF-8?q?=20=E5=8A=A0=E9=80=9F=EF=BC=89=20(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add transcribe_chat_whisper_cpp.py: macOS whisper.cpp transcription whisper.cpp variant of transcribe_chat.py for Apple Silicon Macs. Advantages over transcribe_chat.py: - Uses whisper-cpp CLI with Metal/ANE GPU acceleration (3-5x faster) - No PyTorch or openai/whisper Python dependency - Same idempotent, crash-safe design as transcribe_chat.py - Auto-detects model from common macOS locations: ~/Library/Application Support/whisper-cpp/, ~/Library/Application Support/Recordly/whisper/, etc. - --model-size flag for automatic download if no model found - Configurable --language (default: zh) and --threads Usage: python3 transcribe_chat_whisper_cpp.py [output.json] * refactor: 将 whisper.cpp 转为后端选项集成到 mcp_server.py 中 根据 PR #78 review 反馈,将独立的 transcribe_chat_whisper_cpp.py 重构为 mcp_server.py 中的 whisper_cpp 后端,与 PR #66 OpenAl 后端模式对齐。 变更: - mcp_server.py: 新增 _transcribe_whisper_cpp()、_resolve_whisper_cpp_binary()、 _resolve_whisper_cpp_model(),更新 _resolve_active_backend()/_cache_signature()/ _transcribe() 以分发至 whisper_cpp 后端 - transcribe_chat.py: 统一入口 mcp_server._transcribe 自动支持新后端, 仅补充了 backend 打印信息 - 删除 transcribe_chat_whisper_cpp.py config.json 启用方式: "transcription_backend": "whisper_cpp", "whisper_cpp_binary": "...", # 可选,默认自动检测 "whisper_cpp_model": "...", # 可选,默认自动检测 "whisper_cpp_language": "zh", # 可选 "whisper_cpp_threads": 4 # 可选,默认自动检测 * docs: 在语音转录隐私章节补充 whisper.cpp 后端说明 根据 PR #78 review 反馈,在 README.md ⚠️ 语音转录隐私章节 新增 whisper.cpp 后端(macOS Metal GPU 加速)的配置说明、隐私 属性和回退行为,与 OpenAI 后端并列。 --- README.md | 19 +++++-- mcp_server.py | 131 +++++++++++++++++++++++++++++++++++++++++++-- transcribe_chat.py | 5 +- 3 files changed, 148 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index c74e491..3205b07 100644 --- a/README.md +++ b/README.md @@ -221,9 +221,22 @@ claude mcp add wechat -- python C:\Users\你的用户名\wechat-decrypt\mcp_serv - 成本:约 $0.006 / 分钟(OpenAI 计价) - 文件 > 25MB 在上传前被拒绝(OpenAI 上限) -- 首次启用云后端时 stderr 会打一行警告 -- `transcription_backend` 或 `openai_api_key` 任一缺失时静默回退 local -- 切换后端后,旧缓存条目(backend 不匹配)会自动重新转录 + +如需切换到 whisper.cpp 后端(macOS Metal GPU 加速,3-5x 更快),在 `config.json` 中: + +```json +{ + "transcription_backend": "whisper_cpp" +} +``` + +数据全程留在本机,不上传。需要 `brew install whisper-cpp` 并下载模型(自动检测常见路径,或通过 `whisper_cpp_binary` / `whisper_cpp_model` 指定)。 + +所有后端共用以下行为: +- 首次启用 openai 或 whisper_cpp 后端时 stderr 会打一行警告 +- openai: `openai_api_key` 缺失时静默回退 local +- whisper_cpp: 二进制文件未找到时静默回退 local +- 切换后端后,旧缓存条目(backend 不匹配)自动重新转录 **[查看使用案例 →](USAGE.md)** diff --git a/mcp_server.py b/mcp_server.py index 744a362..619af79 100644 --- a/mcp_server.py +++ b/mcp_server.py @@ -6,7 +6,7 @@ Runs on Windows Python (needs access to D:\ WeChat databases). """ import io -import os, sys, json, time, sqlite3, tempfile, struct, hashlib, atexit, re, threading +import os, sys, json, time, sqlite3, tempfile, struct, hashlib, atexit, re, threading, subprocess import glob import wave import hmac as hmac_mod @@ -2771,9 +2771,71 @@ _openai_client = None _openai_warning_emitted = False _fallback_warning_emitted = False +# whisper.cpp 后端(macOS Metal GPU 加速) +# 路径选项均为可选,默认自动检测 +WHISPER_CPP_BINARY = _cfg.get("whisper_cpp_binary", "") +WHISPER_CPP_MODEL = _cfg.get("whisper_cpp_model", "") +WHISPER_CPP_LANGUAGE = _cfg.get("whisper_cpp_language", "zh") +WHISPER_CPP_THREADS = _cfg.get("whisper_cpp_threads", 0) + +_WHISPER_CPP_BINARY_SEARCH_PATHS = [ + "/opt/homebrew/bin/whisper-cpp", + "/usr/local/bin/whisper-cpp", + os.path.expanduser("~/.local/bin/whisper-cpp"), +] + +_WHISPER_CPP_MODEL_SEARCH_PATHS = [ + os.path.expanduser("~/Library/Application Support/whisper-cpp"), + os.path.expanduser("~/Library/Application Support/Recordly/whisper"), + os.path.expanduser("~/whisper-models"), + os.path.expanduser("~/models"), + os.path.expanduser("~/Downloads"), + "/opt/homebrew/share/whisper-cpp/models", + "/usr/local/share/whisper-cpp/models", +] + +_whisper_cpp_binary_resolved = None # None=未检测, ""=未找到, str=路径 +_whisper_cpp_model_resolved = None # 同上 + + +def _resolve_whisper_cpp_binary(): + global _whisper_cpp_binary_resolved + if _whisper_cpp_binary_resolved is not None: + return _whisper_cpp_binary_resolved + if WHISPER_CPP_BINARY: + if os.path.isfile(WHISPER_CPP_BINARY) and os.access(WHISPER_CPP_BINARY, os.X_OK): + _whisper_cpp_binary_resolved = WHISPER_CPP_BINARY + return _whisper_cpp_binary_resolved + for p in _WHISPER_CPP_BINARY_SEARCH_PATHS: + if os.path.isfile(p) and os.access(p, os.X_OK): + _whisper_cpp_binary_resolved = p + return _whisper_cpp_binary_resolved + _whisper_cpp_binary_resolved = "" + return "" + + +def _resolve_whisper_cpp_model(): + global _whisper_cpp_model_resolved + if _whisper_cpp_model_resolved is not None: + return _whisper_cpp_model_resolved + if WHISPER_CPP_MODEL: + if os.path.isfile(WHISPER_CPP_MODEL): + _whisper_cpp_model_resolved = WHISPER_CPP_MODEL + return _whisper_cpp_model_resolved + for search_dir in _WHISPER_CPP_MODEL_SEARCH_PATHS: + if not os.path.isdir(search_dir): + continue + for f in sorted(os.listdir(search_dir)): + if f.startswith("ggml-") and f.endswith(".bin"): + _whisper_cpp_model_resolved = os.path.join(search_dir, f) + return _whisper_cpp_model_resolved + _whisper_cpp_model_resolved = "" + return "" + def _resolve_active_backend(): - """两因素 opt-in:openai 需要 flag + key 都齐才生效。""" + """两因素 opt-in:openai 需要 flag + key 都齐才生效。 + whisper_cpp 需要 binary 可检测到,否则回退 local。""" global _fallback_warning_emitted if TRANSCRIPTION_BACKEND == "openai": if not OPENAI_API_KEY: @@ -2786,6 +2848,18 @@ def _resolve_active_backend(): _fallback_warning_emitted = True return "local" return "openai" + if TRANSCRIPTION_BACKEND == "whisper_cpp": + if not _resolve_whisper_cpp_binary(): + if not _fallback_warning_emitted: + print( + "[whisper] transcription_backend=whisper_cpp 但未找到 " + "whisper-cpp 二进制文件,回退到本地模型。" + "安装: brew install whisper-cpp", + file=sys.stderr, flush=True, + ) + _fallback_warning_emitted = True + return "local" + return "whisper_cpp" return "local" @@ -2794,6 +2868,10 @@ def _cache_signature(): backend = _resolve_active_backend() if backend == "openai": return {"backend": "openai", "model_size": OPENAI_WHISPER_MODEL} + if backend == "whisper_cpp": + model_path = _resolve_whisper_cpp_model() + model_name = os.path.basename(model_path) if model_path else "unknown" + return {"backend": "whisper_cpp", "model_size": model_name} return {"backend": "local", "model_size": LOCAL_WHISPER_MODEL} @@ -2865,9 +2943,55 @@ def _transcribe_openai(wav_path): } +def _transcribe_whisper_cpp(wav_path): + """通过 whisper-cpp CLI(Metal GPU 加速)转录。失败抛 RuntimeError。""" + binary = _resolve_whisper_cpp_binary() + if not binary: + raise RuntimeError("whisper-cpp binary 未找到。安装: brew install whisper-cpp") + model = _resolve_whisper_cpp_model() + if not model: + raise RuntimeError( + "whisper.cpp 模型未找到。通过 config.json whisper_cpp_model 指定路径," + "或下载: https://huggingface.co/ggerganov/whisper.cpp" + ) + + threads = WHISPER_CPP_THREADS + if not threads: + try: + threads = min(os.cpu_count() or 4, 8) + except Exception: + threads = 4 + + try: + cmd = [ + binary, + "-m", model, + "-f", wav_path, + "-l", WHISPER_CPP_LANGUAGE, + "-t", str(threads), + "--no-fallback", + "-otxt", + ] + subprocess.run(cmd, capture_output=True, text=True, timeout=120) + + txt_path = f"{wav_path}.txt" + if os.path.isfile(txt_path): + with open(txt_path, encoding="utf-8") as f: + text = f.read().strip() + os.unlink(txt_path) + return {"language": WHISPER_CPP_LANGUAGE, "text": text or ""} + return {"language": WHISPER_CPP_LANGUAGE, "text": ""} + except subprocess.TimeoutExpired: + raise RuntimeError("whisper-cpp 超时 (120s)") + except Exception as e: + raise RuntimeError(f"whisper-cpp 转录失败: {e}") + + def _transcribe(wav_path, backend): if backend == "openai": return _transcribe_openai(wav_path) + if backend == "whisper_cpp": + return _transcribe_whisper_cpp(wav_path) return _transcribe_local(wav_path) @@ -2880,13 +3004,14 @@ def transcribe_voice(chat_name: str, local_id: int) -> str: 和 Whisper 推理)。后端切换或本地模型升级(如 base → small)后, 旧条目自动视为失效并重新转录。首次运行本地模型会下载约 145MB 权重。 - 后端由 config.json 中 transcription_backend 字段控制(local/openai)。 + 后端由 config.json 中 transcription_backend 字段控制(local/openai/whisper_cpp)。 详见 README "语音转录隐私" 章节。 依赖: - 本地后端: pip install silk-python openai-whisper (silk-python 的 import 名为 pysilk) - OpenAI 后端: pip install silk-python openai + - whisper_cpp 后端: brew install whisper-cpp (macOS) Args: chat_name: 聊天对象的名字、备注名或wxid diff --git a/transcribe_chat.py b/transcribe_chat.py index 9032fcc..ca55f61 100644 --- a/transcribe_chat.py +++ b/transcribe_chat.py @@ -13,10 +13,11 @@ .venv/bin/python3 transcribe_chat.py /tmp/chat.json /tmp/chat_transcribed.json 行为说明: - - 后端由 config.json 中 transcription_backend 字段控制 (local/openai), + - 后端由 config.json 中 transcription_backend 字段控制 (local/openai/whisper_cpp), 与 MCP transcribe_voice 工具共享配置。详见 README "语音转录隐私" 章节。 - 默认 local: 使用本地 Whisper (CPU,单线程),首次运行下载 ~145 MB 权重。 - 切到 openai: 语音上传至 OpenAI 服务器转录 (~$0.006/分钟)。 + - 切到 whisper_cpp: 使用 whisper-cpp CLI (Metal GPU 加速,仅 macOS)。 - 幂等: 已有 "transcription" 字段的消息会被跳过,因此崩溃/中断后可安全重跑。 - 崩溃安全: 每处理完一条即整体重写输出 JSON,进程中断最多丢失当前一条。 @@ -78,6 +79,8 @@ def transcribe_export(input_path, output_path): print("Loading Whisper model (first run downloads ~145MB)...") mcp_server._get_whisper_model() print("Model ready.\n") + elif backend == "whisper_cpp": + print("Using whisper-cpp with Metal GPU acceleration\n") else: print("")