feat: transcribe_voice 新增 whisper.cpp 后端(macOS Metal GPU 加速) (#78)
* 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 <input.json> [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 后端并列。
This commit is contained in:
19
README.md
19
README.md
@@ -221,9 +221,22 @@ claude mcp add wechat -- python C:\Users\你的用户名\wechat-decrypt\mcp_serv
|
|||||||
|
|
||||||
- 成本:约 $0.006 / 分钟(OpenAI 计价)
|
- 成本:约 $0.006 / 分钟(OpenAI 计价)
|
||||||
- 文件 > 25MB 在上传前被拒绝(OpenAI 上限)
|
- 文件 > 25MB 在上传前被拒绝(OpenAI 上限)
|
||||||
- 首次启用云后端时 stderr 会打一行警告
|
|
||||||
- `transcription_backend` 或 `openai_api_key` 任一缺失时静默回退 local
|
如需切换到 whisper.cpp 后端(macOS Metal GPU 加速,3-5x 更快),在 `config.json` 中:
|
||||||
- 切换后端后,旧缓存条目(backend 不匹配)会自动重新转录
|
|
||||||
|
```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)**
|
**[查看使用案例 →](USAGE.md)**
|
||||||
|
|
||||||
|
|||||||
131
mcp_server.py
131
mcp_server.py
@@ -6,7 +6,7 @@ Runs on Windows Python (needs access to D:\ WeChat databases).
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import io
|
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 glob
|
||||||
import wave
|
import wave
|
||||||
import hmac as hmac_mod
|
import hmac as hmac_mod
|
||||||
@@ -2771,9 +2771,71 @@ _openai_client = None
|
|||||||
_openai_warning_emitted = False
|
_openai_warning_emitted = False
|
||||||
_fallback_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():
|
def _resolve_active_backend():
|
||||||
"""两因素 opt-in:openai 需要 flag + key 都齐才生效。"""
|
"""两因素 opt-in:openai 需要 flag + key 都齐才生效。
|
||||||
|
whisper_cpp 需要 binary 可检测到,否则回退 local。"""
|
||||||
global _fallback_warning_emitted
|
global _fallback_warning_emitted
|
||||||
if TRANSCRIPTION_BACKEND == "openai":
|
if TRANSCRIPTION_BACKEND == "openai":
|
||||||
if not OPENAI_API_KEY:
|
if not OPENAI_API_KEY:
|
||||||
@@ -2786,6 +2848,18 @@ def _resolve_active_backend():
|
|||||||
_fallback_warning_emitted = True
|
_fallback_warning_emitted = True
|
||||||
return "local"
|
return "local"
|
||||||
return "openai"
|
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"
|
return "local"
|
||||||
|
|
||||||
|
|
||||||
@@ -2794,6 +2868,10 @@ def _cache_signature():
|
|||||||
backend = _resolve_active_backend()
|
backend = _resolve_active_backend()
|
||||||
if backend == "openai":
|
if backend == "openai":
|
||||||
return {"backend": "openai", "model_size": OPENAI_WHISPER_MODEL}
|
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}
|
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):
|
def _transcribe(wav_path, backend):
|
||||||
if backend == "openai":
|
if backend == "openai":
|
||||||
return _transcribe_openai(wav_path)
|
return _transcribe_openai(wav_path)
|
||||||
|
if backend == "whisper_cpp":
|
||||||
|
return _transcribe_whisper_cpp(wav_path)
|
||||||
return _transcribe_local(wav_path)
|
return _transcribe_local(wav_path)
|
||||||
|
|
||||||
|
|
||||||
@@ -2880,13 +3004,14 @@ def transcribe_voice(chat_name: str, local_id: int) -> str:
|
|||||||
和 Whisper 推理)。后端切换或本地模型升级(如 base → small)后,
|
和 Whisper 推理)。后端切换或本地模型升级(如 base → small)后,
|
||||||
旧条目自动视为失效并重新转录。首次运行本地模型会下载约 145MB 权重。
|
旧条目自动视为失效并重新转录。首次运行本地模型会下载约 145MB 权重。
|
||||||
|
|
||||||
后端由 config.json 中 transcription_backend 字段控制(local/openai)。
|
后端由 config.json 中 transcription_backend 字段控制(local/openai/whisper_cpp)。
|
||||||
详见 README "语音转录隐私" 章节。
|
详见 README "语音转录隐私" 章节。
|
||||||
|
|
||||||
依赖:
|
依赖:
|
||||||
- 本地后端: pip install silk-python openai-whisper
|
- 本地后端: pip install silk-python openai-whisper
|
||||||
(silk-python 的 import 名为 pysilk)
|
(silk-python 的 import 名为 pysilk)
|
||||||
- OpenAI 后端: pip install silk-python openai
|
- OpenAI 后端: pip install silk-python openai
|
||||||
|
- whisper_cpp 后端: brew install whisper-cpp (macOS)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
chat_name: 聊天对象的名字、备注名或wxid
|
chat_name: 聊天对象的名字、备注名或wxid
|
||||||
|
|||||||
@@ -13,10 +13,11 @@
|
|||||||
.venv/bin/python3 transcribe_chat.py /tmp/chat.json /tmp/chat_transcribed.json
|
.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 "语音转录隐私" 章节。
|
与 MCP transcribe_voice 工具共享配置。详见 README "语音转录隐私" 章节。
|
||||||
- 默认 local: 使用本地 Whisper (CPU,单线程),首次运行下载 ~145 MB 权重。
|
- 默认 local: 使用本地 Whisper (CPU,单线程),首次运行下载 ~145 MB 权重。
|
||||||
- 切到 openai: 语音上传至 OpenAI 服务器转录 (~$0.006/分钟)。
|
- 切到 openai: 语音上传至 OpenAI 服务器转录 (~$0.006/分钟)。
|
||||||
|
- 切到 whisper_cpp: 使用 whisper-cpp CLI (Metal GPU 加速,仅 macOS)。
|
||||||
- 幂等: 已有 "transcription" 字段的消息会被跳过,因此崩溃/中断后可安全重跑。
|
- 幂等: 已有 "transcription" 字段的消息会被跳过,因此崩溃/中断后可安全重跑。
|
||||||
- 崩溃安全: 每处理完一条即整体重写输出 JSON,进程中断最多丢失当前一条。
|
- 崩溃安全: 每处理完一条即整体重写输出 JSON,进程中断最多丢失当前一条。
|
||||||
|
|
||||||
@@ -78,6 +79,8 @@ def transcribe_export(input_path, output_path):
|
|||||||
print("Loading Whisper model (first run downloads ~145MB)...")
|
print("Loading Whisper model (first run downloads ~145MB)...")
|
||||||
mcp_server._get_whisper_model()
|
mcp_server._get_whisper_model()
|
||||||
print("Model ready.\n")
|
print("Model ready.\n")
|
||||||
|
elif backend == "whisper_cpp":
|
||||||
|
print("Using whisper-cpp with Metal GPU acceleration\n")
|
||||||
else:
|
else:
|
||||||
print("")
|
print("")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user