feat: 新增语音 MCP 工具 + macOS 密钥提取修复 (#53)
* feat: 新增语音 MCP 工具 + macOS 密钥提取修复 - 新增 get_voice_messages / decode_voice / transcribe_voice MCP 工具 - 语音数据存储在 media_0.db VoiceInfo 表(SILK v3 格式) - decode_voice 解码为 WAV 文件(saved to decoded_voices/) - transcribe_voice 通过 Whisper 自动识别语言转录 - 新增 get_chat_history oldest_first 参数,支持从最早消息开始分页 - 修复 macOS 下 check_wechat_running / ensure_keys 逻辑 - 改用 pgrep 检测微信进程,绕过不支持 macOS 的 Python 扫描器 - 无 all_keys.json 时打印清晰引导,提示运行 C 版扫描器 - 新增 Makefile(build / keys / decrypt / web 快捷命令) - .gitignore 补充 find_all_keys_macos 二进制和 decoded_voices/ * fix: 语音查询支持多分片 media DB + 文件名唯一化 解决 PR #53 review 的阻塞项 #1,顺手修 #3、#6。 #1 `_get_media_db_path()` 硬编码 `media_0.db` - 新增模块级 `MEDIA_DB_KEYS`,镜像 `MSG_DB_KEYS` 的分片发现逻辑 - `_fetch_voice_row` 遍历所有分片,按 `(chat_name_id, local_id)` 首个命中即返回;单条语音在 media DB 家族内唯一,命中即可停 - `get_voice_messages` 从每个分片各取 `LIMIT limit`,合并排序后 截断到 `limit`。选择"每分片取 limit 条再合并"而非"按 max(create_time) 排序后逐个取到 limit 即停止":后者假设分片 间时间不重叠,一旦 WeChat 改分片策略就会静默丢消息;前者工作 量 O(N 分片 × limit),在任何分片布局下都正确 #3 输出文件名冲突 - `_silk_to_wav` 增加 `local_id` 参数,输出 `{user}_{time}_{lid}.wav`, 同一秒内两条语音不会互相覆盖;两个调用方都已在作用域内持有 `local_id` #6 `_fetch_voice_row` 的 `local_id=None` 死分支 - 随 #1 的重写一并删除,`local_id` 改为必填位置参数 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor: macOS 密钥提取分层下沉到 find_all_keys.py 解决 PR #53 review 的阻塞项 #2。 review 里提到"跟 PR #51 冲突"实测不存在 —— PR #51 当前 0 文件改动 (fork 分支已与上游同步),但架构建议本身是对的:macOS 处理应集中 在 `find_all_keys.py`,而不是在 `main.py` 提前 return 截胡。 - `main.py:ensure_keys()` 移除 darwin 专属提前返回分支,macOS 走 和其他平台相同的 `extract_keys()` 路径 - `find_all_keys.py:_load_impl()` 在 darwin 分支抛出带 `sudo ./find_all_keys_macos` 操作指引的 RuntimeError;非 macOS 的平台兜底分支保留 - `main.py` 里已有 `except RuntimeError` 会打印并 `sys.exit(1)`, 用户可见行为不变 未来若有 PR 在 `find_all_keys.py` 加 macOS 自动编译 / dispatch, 直接替换这段 RuntimeError 即可,不再需要改 `main.py`。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore: Makefile 支持 PYTHON 变量覆盖 解决 PR #53 review 的非阻塞项 #7。 原 Makefile 硬编码 `.venv/bin/python3`,没有 venv 的用户跑 `make decrypt` 直接报错。引入 `PYTHON ?= .venv/bin/python3`:默认行为 不变(仍走 venv),想用系统 Python 的用户 `PYTHON=python3 make decrypt` 即可。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * docs: 回应 PR #53 review #4 — 澄清 silk-python 与 pysilk 包名关系 验证:本项目 import 的 `pysilk` 实际由 `pip install silk-python` (synodriver/pysilk) 提供;pypi 上另有同名 `pysilk==0.0.1` 是无内容 的占位包,不可用。错误消息里 `pip install silk-python` 已经是对的, 但 reader 看到 `import pysilk` 仍会困惑,所以: - `_silk_to_wav` 的 import 处加一行注释,点名所用的是 synodriver 版本,并提醒 pypi 上还有 pilk / pysilk 两个同类包 - `decode_voice` / `transcribe_voice` 的 docstring 加 "依赖:" 行, 明确 "pip install silk-python (import 名为 pysilk)",MCP 客户端 读 tool 描述就能看到正确的安装命令 未新增 requirements.txt 条目:voice 支持是可选功能(tool 内 try/except ImportError 懒加载),保持非必需依赖的语义。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -23,3 +23,7 @@ __pycache__/
|
|||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Compiled binaries and output
|
||||||
|
find_all_keys_macos
|
||||||
|
decoded_voices/
|
||||||
|
|||||||
16
Makefile
Normal file
16
Makefile
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
.PHONY: keys decrypt web build
|
||||||
|
|
||||||
|
PYTHON ?= .venv/bin/python3
|
||||||
|
|
||||||
|
build:
|
||||||
|
cc -O2 -o find_all_keys_macos find_all_keys_macos.c -framework Foundation
|
||||||
|
codesign -s - find_all_keys_macos
|
||||||
|
|
||||||
|
keys:
|
||||||
|
sudo ./find_all_keys_macos
|
||||||
|
|
||||||
|
decrypt:
|
||||||
|
$(PYTHON) main.py decrypt
|
||||||
|
|
||||||
|
web:
|
||||||
|
$(PYTHON) main.py
|
||||||
@@ -12,9 +12,16 @@ def _load_impl():
|
|||||||
if system == "linux":
|
if system == "linux":
|
||||||
import find_all_keys_linux as impl
|
import find_all_keys_linux as impl
|
||||||
return impl
|
return impl
|
||||||
|
if system == "darwin":
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"当前平台暂不支持通过 find_all_keys.py 提取密钥: {platform.system()}\n"
|
"macOS 请先运行 C 版扫描器提取密钥:\n"
|
||||||
f"macOS 请使用 find_all_keys_macos.c (C 版扫描器)"
|
"\n"
|
||||||
|
" sudo ./find_all_keys_macos\n"
|
||||||
|
"\n"
|
||||||
|
" 完成后再运行 python main.py decrypt"
|
||||||
|
)
|
||||||
|
raise RuntimeError(
|
||||||
|
f"当前平台暂不支持通过 find_all_keys.py 提取密钥: {platform.system()}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
4
main.py
4
main.py
@@ -6,7 +6,9 @@ python main.py decrypt # 提取密钥 + 解密全部数据库
|
|||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import platform
|
||||||
import sys
|
import sys
|
||||||
|
import subprocess
|
||||||
|
|
||||||
import functools
|
import functools
|
||||||
print = functools.partial(print, flush=True)
|
print = functools.partial(print, flush=True)
|
||||||
@@ -16,6 +18,8 @@ from key_utils import strip_key_metadata
|
|||||||
|
|
||||||
def check_wechat_running():
|
def check_wechat_running():
|
||||||
"""检查微信是否在运行,返回 True/False"""
|
"""检查微信是否在运行,返回 True/False"""
|
||||||
|
if platform.system().lower() == "darwin":
|
||||||
|
return subprocess.run(["pgrep", "-x", "WeChat"], capture_output=True).returncode == 0
|
||||||
from find_all_keys import get_pids
|
from find_all_keys import get_pids
|
||||||
try:
|
try:
|
||||||
get_pids()
|
get_pids()
|
||||||
|
|||||||
222
mcp_server.py
222
mcp_server.py
@@ -5,7 +5,9 @@ Based on FastMCP (stdio transport), reuses existing decryption.
|
|||||||
Runs on Windows Python (needs access to D:\ WeChat databases).
|
Runs on Windows Python (needs access to D:\ WeChat databases).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
import os, sys, json, time, sqlite3, tempfile, struct, hashlib, atexit, re
|
import os, sys, json, time, sqlite3, tempfile, struct, hashlib, atexit, re
|
||||||
|
import wave
|
||||||
import hmac as hmac_mod
|
import hmac as hmac_mod
|
||||||
from contextlib import closing
|
from contextlib import closing
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -803,18 +805,19 @@ def _build_message_filters(start_ts=None, end_ts=None, keyword=''):
|
|||||||
return clauses, params
|
return clauses, params
|
||||||
|
|
||||||
|
|
||||||
def _query_messages(conn, table_name, start_ts=None, end_ts=None, keyword='', limit=20, offset=0):
|
def _query_messages(conn, table_name, start_ts=None, end_ts=None, keyword='', limit=20, offset=0, oldest_first=False):
|
||||||
if not _is_safe_msg_table_name(table_name):
|
if not _is_safe_msg_table_name(table_name):
|
||||||
raise ValueError(f'非法消息表名: {table_name}')
|
raise ValueError(f'非法消息表名: {table_name}')
|
||||||
|
|
||||||
clauses, params = _build_message_filters(start_ts, end_ts, keyword)
|
clauses, params = _build_message_filters(start_ts, end_ts, keyword)
|
||||||
where_sql = f"WHERE {' AND '.join(clauses)}" if clauses else ''
|
where_sql = f"WHERE {' AND '.join(clauses)}" if clauses else ''
|
||||||
|
order = 'ASC' if oldest_first else 'DESC'
|
||||||
sql = f"""
|
sql = f"""
|
||||||
SELECT local_id, local_type, create_time, real_sender_id, message_content,
|
SELECT local_id, local_type, create_time, real_sender_id, message_content,
|
||||||
WCDB_CT_message_content
|
WCDB_CT_message_content
|
||||||
FROM [{table_name}]
|
FROM [{table_name}]
|
||||||
{where_sql}
|
{where_sql}
|
||||||
ORDER BY create_time DESC
|
ORDER BY create_time {order}
|
||||||
"""
|
"""
|
||||||
if limit is None:
|
if limit is None:
|
||||||
return conn.execute(sql, params).fetchall()
|
return conn.execute(sql, params).fetchall()
|
||||||
@@ -994,14 +997,14 @@ def _history_query_batch_size(candidate_limit):
|
|||||||
return min(candidate_limit, _HISTORY_QUERY_BATCH_SIZE)
|
return min(candidate_limit, _HISTORY_QUERY_BATCH_SIZE)
|
||||||
|
|
||||||
|
|
||||||
def _page_ranked_entries(entries, limit, offset):
|
def _page_ranked_entries(entries, limit, offset, oldest_first=False):
|
||||||
ordered = sorted(entries, key=lambda item: item[0], reverse=True)
|
ordered = sorted(entries, key=lambda item: item[0], reverse=not oldest_first)
|
||||||
paged = ordered[offset:offset + limit]
|
paged = ordered[offset:offset + limit]
|
||||||
paged.sort(key=lambda item: item[0])
|
paged.sort(key=lambda item: item[0])
|
||||||
return paged
|
return paged
|
||||||
|
|
||||||
|
|
||||||
def _collect_chat_history_lines(ctx, names, start_ts=None, end_ts=None, limit=20, offset=0):
|
def _collect_chat_history_lines(ctx, names, start_ts=None, end_ts=None, limit=20, offset=0, oldest_first=False):
|
||||||
collected = []
|
collected = []
|
||||||
failures = []
|
failures = []
|
||||||
candidate_limit = _candidate_page_size(limit, offset)
|
candidate_limit = _candidate_page_size(limit, offset)
|
||||||
@@ -1022,6 +1025,7 @@ def _collect_chat_history_lines(ctx, names, start_ts=None, end_ts=None, limit=20
|
|||||||
end_ts=end_ts,
|
end_ts=end_ts,
|
||||||
limit=batch_size,
|
limit=batch_size,
|
||||||
offset=fetch_offset,
|
offset=fetch_offset,
|
||||||
|
oldest_first=oldest_first,
|
||||||
)
|
)
|
||||||
if not rows:
|
if not rows:
|
||||||
break
|
break
|
||||||
@@ -1042,7 +1046,7 @@ def _collect_chat_history_lines(ctx, names, start_ts=None, end_ts=None, limit=20
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
failures.append(f"{table_ctx['db_path']}: {e}")
|
failures.append(f"{table_ctx['db_path']}: {e}")
|
||||||
|
|
||||||
paged = _page_ranked_entries(collected, limit, offset)
|
paged = _page_ranked_entries(collected, limit, offset, oldest_first=oldest_first)
|
||||||
return [line for _, line in paged], failures
|
return [line for _, line in paged], failures
|
||||||
|
|
||||||
|
|
||||||
@@ -1348,7 +1352,7 @@ def get_recent_sessions(limit: int = 20) -> str:
|
|||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def get_chat_history(chat_name: str, limit: int = 50, offset: int = 0, start_time: str = "", end_time: str = "") -> str:
|
def get_chat_history(chat_name: str, limit: int = 50, offset: int = 0, start_time: str = "", end_time: str = "", oldest_first: bool = False) -> str:
|
||||||
"""获取指定聊天的消息记录。
|
"""获取指定聊天的消息记录。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -1357,6 +1361,7 @@ def get_chat_history(chat_name: str, limit: int = 50, offset: int = 0, start_tim
|
|||||||
offset: 分页偏移量,默认0
|
offset: 分页偏移量,默认0
|
||||||
start_time: 起始时间,支持 YYYY-MM-DD / YYYY-MM-DD HH:MM / YYYY-MM-DD HH:MM:SS
|
start_time: 起始时间,支持 YYYY-MM-DD / YYYY-MM-DD HH:MM / YYYY-MM-DD HH:MM:SS
|
||||||
end_time: 结束时间,支持 YYYY-MM-DD / YYYY-MM-DD HH:MM / YYYY-MM-DD HH:MM:SS
|
end_time: 结束时间,支持 YYYY-MM-DD / YYYY-MM-DD HH:MM / YYYY-MM-DD HH:MM:SS
|
||||||
|
oldest_first: 为 True 时返回最早的消息(默认 False 返回最新消息)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
_validate_pagination(limit, offset, limit_max=None)
|
_validate_pagination(limit, offset, limit_max=None)
|
||||||
@@ -1378,6 +1383,7 @@ def get_chat_history(chat_name: str, limit: int = 50, offset: int = 0, start_tim
|
|||||||
end_ts=end_ts,
|
end_ts=end_ts,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
offset=offset,
|
offset=offset,
|
||||||
|
oldest_first=oldest_first,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not lines:
|
if not lines:
|
||||||
@@ -1734,5 +1740,207 @@ def get_chat_images(chat_name: str, limit: int = 20) -> str:
|
|||||||
return f"{display_name} 的 {len(lines)} 张图片:\n\n" + "\n".join(lines)
|
return f"{display_name} 的 {len(lines)} 张图片:\n\n" + "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# ============ 语音解密 ============
|
||||||
|
|
||||||
|
DECODED_VOICE_DIR = os.path.join(SCRIPT_DIR, "decoded_voices")
|
||||||
|
|
||||||
|
# media DB 与 message DB 同样会分片(media_0.db、media_1.db…),
|
||||||
|
# 每个分片各有独立的 Name2Id / VoiceInfo 表。
|
||||||
|
MEDIA_DB_KEYS = sorted([
|
||||||
|
k for k in ALL_KEYS
|
||||||
|
if any(v.startswith("message/") for v in key_path_variants(k))
|
||||||
|
and any(re.search(r"media_\d+\.db$", v) for v in key_path_variants(k))
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_media_db_paths():
|
||||||
|
for rel_key in MEDIA_DB_KEYS:
|
||||||
|
path = _cache.get(rel_key)
|
||||||
|
if path:
|
||||||
|
yield path
|
||||||
|
|
||||||
|
|
||||||
|
def _get_chat_name_id(conn, username):
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT rowid FROM Name2Id WHERE user_name = ?", (username,)
|
||||||
|
).fetchone()
|
||||||
|
return row[0] if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_voice_row(username, local_id):
|
||||||
|
"""遍历所有 media DB 分片,返回 (voice_data, create_time);找不到返回 None。"""
|
||||||
|
for media_db in _iter_media_db_paths():
|
||||||
|
with closing(sqlite3.connect(media_db)) as conn:
|
||||||
|
chat_name_id = _get_chat_name_id(conn, username)
|
||||||
|
if chat_name_id is None:
|
||||||
|
continue
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT voice_data, create_time FROM VoiceInfo "
|
||||||
|
"WHERE chat_name_id = ? AND local_id = ?",
|
||||||
|
(chat_name_id, local_id),
|
||||||
|
).fetchone()
|
||||||
|
if row:
|
||||||
|
return row
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _silk_to_wav(voice_data, create_time, username, local_id):
|
||||||
|
"""Decode SILK voice blob to WAV file, return output path."""
|
||||||
|
# pypi 上有多个 SILK 相关包名(silk-python / pysilk / pilk),
|
||||||
|
# 这里用的是 synodriver/pysilk —— 安装包名 silk-python,import 名 pysilk
|
||||||
|
import pysilk
|
||||||
|
data = bytes(voice_data)
|
||||||
|
silk_data = data[1:] if data[0] == 0x02 else data
|
||||||
|
os.makedirs(DECODED_VOICE_DIR, exist_ok=True)
|
||||||
|
time_str = datetime.fromtimestamp(create_time).strftime('%Y%m%d_%H%M%S')
|
||||||
|
out_path = os.path.join(DECODED_VOICE_DIR, f"{username}_{time_str}_{local_id}.wav")
|
||||||
|
inp = io.BytesIO(silk_data)
|
||||||
|
out = io.BytesIO()
|
||||||
|
pysilk.decode(inp, out, 24000)
|
||||||
|
pcm = out.getvalue()
|
||||||
|
with wave.open(out_path, 'wb') as wf:
|
||||||
|
wf.setnchannels(1)
|
||||||
|
wf.setsampwidth(2)
|
||||||
|
wf.setframerate(24000)
|
||||||
|
wf.writeframes(pcm)
|
||||||
|
return out_path, len(pcm)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def get_voice_messages(chat_name: str, limit: int = 20) -> str:
|
||||||
|
"""列出某个聊天中的语音消息。
|
||||||
|
|
||||||
|
返回语音的时间、local_id 和大小,可配合 decode_voice 工具解码。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
chat_name: 聊天对象的名字、备注名或wxid
|
||||||
|
limit: 返回数量,默认20
|
||||||
|
"""
|
||||||
|
username = resolve_username(chat_name)
|
||||||
|
if not username:
|
||||||
|
return f"找不到聊天对象: {chat_name}"
|
||||||
|
|
||||||
|
names = get_contact_names()
|
||||||
|
display_name = names.get(username, username)
|
||||||
|
|
||||||
|
if not MEDIA_DB_KEYS:
|
||||||
|
return "找不到 media DB"
|
||||||
|
|
||||||
|
# 从每个分片各取最多 limit 条后合并再截断:分片若有时间重叠也不会漏最新消息
|
||||||
|
rows = []
|
||||||
|
for media_db in _iter_media_db_paths():
|
||||||
|
with closing(sqlite3.connect(media_db)) as conn:
|
||||||
|
chat_name_id = _get_chat_name_id(conn, username)
|
||||||
|
if chat_name_id is None:
|
||||||
|
continue
|
||||||
|
rows.extend(conn.execute(
|
||||||
|
"SELECT local_id, create_time, length(voice_data) FROM VoiceInfo "
|
||||||
|
"WHERE chat_name_id = ? ORDER BY create_time DESC LIMIT ?",
|
||||||
|
(chat_name_id, limit),
|
||||||
|
).fetchall())
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
return f"{display_name} 无语音消息"
|
||||||
|
|
||||||
|
rows.sort(key=lambda r: r[1], reverse=True)
|
||||||
|
rows = rows[:limit]
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
for local_id, create_time, size in rows:
|
||||||
|
time_str = datetime.fromtimestamp(create_time).strftime('%Y-%m-%d %H:%M')
|
||||||
|
lines.append(f"[{time_str}] local_id={local_id} {size/1024:.0f}KB")
|
||||||
|
|
||||||
|
return f"{display_name} 的 {len(lines)} 条语音消息:\n\n" + "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def decode_voice(chat_name: str, local_id: int) -> str:
|
||||||
|
"""解码微信语音消息为 WAV 文件。
|
||||||
|
|
||||||
|
先用 get_voice_messages 获取 local_id,再用此工具解码。
|
||||||
|
输出文件保存在 decoded_voices/ 目录。
|
||||||
|
|
||||||
|
依赖: pip install silk-python (import 名为 pysilk)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
chat_name: 聊天对象的名字、备注名或wxid
|
||||||
|
local_id: 语音消息的 local_id(从 get_voice_messages 获取)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import pysilk # noqa: F401
|
||||||
|
except ImportError:
|
||||||
|
return "缺少依赖: pip install silk-python"
|
||||||
|
|
||||||
|
username = resolve_username(chat_name)
|
||||||
|
if not username:
|
||||||
|
return f"找不到聊天对象: {chat_name}"
|
||||||
|
|
||||||
|
row = _fetch_voice_row(username, local_id)
|
||||||
|
if row is None:
|
||||||
|
return f"找不到 local_id={local_id} 的语音消息"
|
||||||
|
|
||||||
|
voice_data, create_time = row
|
||||||
|
out_path, pcm_len = _silk_to_wav(voice_data, create_time, username, local_id)
|
||||||
|
duration_s = pcm_len / (24000 * 2)
|
||||||
|
return (
|
||||||
|
f"解码成功!\n"
|
||||||
|
f" 文件: {out_path}\n"
|
||||||
|
f" 时长: {duration_s:.1f}秒\n"
|
||||||
|
f" 大小: {os.path.getsize(out_path):,} bytes"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_whisper_model = None
|
||||||
|
|
||||||
|
def _get_whisper_model(model_size="base"):
|
||||||
|
global _whisper_model
|
||||||
|
if _whisper_model is None:
|
||||||
|
import whisper
|
||||||
|
_whisper_model = whisper.load_model(model_size)
|
||||||
|
return _whisper_model
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def transcribe_voice(chat_name: str, local_id: int) -> str:
|
||||||
|
"""将微信语音消息转录为文字(自动检测语言,保留原语言)。
|
||||||
|
|
||||||
|
会先解码 SILK 语音为 WAV,再用 Whisper 转录。
|
||||||
|
首次运行会下载 Whisper 模型(约 145MB)。
|
||||||
|
|
||||||
|
依赖: pip install silk-python openai-whisper (silk-python 的 import 名为 pysilk)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
chat_name: 聊天对象的名字、备注名或wxid
|
||||||
|
local_id: 语音消息的 local_id(从 get_voice_messages 获取)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import whisper # noqa: F401
|
||||||
|
except ImportError:
|
||||||
|
return "缺少依赖: pip install openai-whisper"
|
||||||
|
try:
|
||||||
|
import pysilk # noqa: F401
|
||||||
|
except ImportError:
|
||||||
|
return "缺少依赖: pip install silk-python"
|
||||||
|
|
||||||
|
username = resolve_username(chat_name)
|
||||||
|
if not username:
|
||||||
|
return f"找不到聊天对象: {chat_name}"
|
||||||
|
|
||||||
|
row = _fetch_voice_row(username, local_id)
|
||||||
|
if row is None:
|
||||||
|
return f"找不到 local_id={local_id} 的语音消息"
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
time_label = datetime.fromtimestamp(create_time).strftime('%Y-%m-%d %H:%M')
|
||||||
|
return f"[{time_label}] ({lang})\n{text}"
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
mcp.run()
|
mcp.run()
|
||||||
|
|||||||
Reference in New Issue
Block a user