很多人的微信聊天记录越攒越多,想备份语音却只能看着聊天窗口里那个播放小圆点干瞪眼。实际上微信 PC 端的本地数据就摊在WeChat Files\<wxid>\Msg\Multi\这个 Multi 文件夹里:消息内容被拆成多个 SQLite 分库,语音消息则以 SILK 编码的 .dat 文件散落在旁边的 Voice 子目录中。这篇文章我要讲的就是完整的数据库解析链路——怎么从msg_*.db里筛出语音消息记录,怎么匹配到对应的语音文件,再把它转成 MP3。整套方案只用免费开源工具,适合那些想彻底搞明白微信本地数据结构、又不想依赖在线解析服务的开发者。
先说清楚边界:以下所有操作只针对你自己设备、自己账号产生的数据,分析时也请务必把微信退出后再复制文件,不要在正在运行的微信目录上直接折腾,否则很容易把数据库弄出不可逆的损坏。搞懂原理之后你会发现,微信的本地语音其实就是一层很薄的格式包装,真正的难点几乎全在“定位文件”这一步。
1. 动手前先看清目录:Multi 文件夹到底装了些什么
1.1 微信数据目录怎么找
微信 PC 版的本地数据默认放在当前用户的文档目录下面,常见路径是C:\Users\<用户名>\Documents\WeChat Files。如果你当初安装时改过位置,可以在微信客户端的“设置 - 文件管理”里直接看到当前的存储路径。
进入WeChat Files后,通常会看到All Users和若干个以wxid_xxx命名的目录,那个wxid_xxx就是你的微信账号标识。每次登录不同账号,微信都会在这里建一个独立目录,账号之间互不干扰。
我这次分析的完整目录结构大致长这样:
WeChat Files\ 9f2...fx7\ Msg\ Multi\ msg_0.db msg_1.db msg_2.db ... Voice\ 3f5a...dat 7b2d...dat Voice2\ ...不同版本会有一些差异,有些版本还会把语音文件直接放在Multi下面,或者多出Media、File、Video之类的子目录,但Msg\Multi这个位置基本是稳定的。你只要把Multi整个文件夹复制到一台装了 Python 的电脑上做离线分析就行。
1.2 Multi 文件夹的核心是分库数据库
msg_0.db、msg_1.db这一系列文件,本质上是同一个大数据库切成的小块,专业叫法叫“分库”。微信之所以要拆,是因为消息量一大,单个 SQLite 文件体积会非常夸张,查询和写入都会变慢。拆开后每张表的结构基本一致,但数据按时间和会话分散在不同分片里。
所以后面所有查询都不要只盯着msg_0.db,必须遍历所有msg_*.db文件,否则导出的语音列表一定不完整。我自己第一次做的时候就只查了msg_0.db,结果导出量少了将近一半,这个坑相当隐蔽。
另外,msg_*.db并不是某一个“语音数据库”,它存的是所有类型的聊天消息。图片、视频、文本、系统提示、语音都混在同一个MSG表里,靠Type字段区分。我们要导出语音,核心就是把这堆混合消息里Type=34的那部分挑出来。
1.3 数据库到底能不能直接打开
msg_*.db表面上是 SQLite 文件,但微信不同版本的处理方式不一样。有的版本直接裸存 SQLite,用命令行工具就能打开;有的版本会做 SQLCipher 加密,文件头是完全随机字节,用普通 sqlite3 打开会直接报错。
判断方法很简单,在命令行里看文件头:
xxd msg_0.db | head如果前 16 字节是53 51 4C 69 74 65 20 66 6F 72 6D 61 74 20 33 00,也就是 “SQLite format 3”,那就能直接打开。如果我朋友机器上那个版本出现了加密的情况,建议先别急着折腾内存提 key,优先考虑用微信自带的“备份与恢复”功能把聊天记录导到手机或其他设备,再从备份目录里拿数据,安全系数高很多。
能直连的情况下,命令行先摸个底:
sqlite3 msg_0.db ".tables" sqlite3 msg_0.db ".schema MSG"第二条命令输出可能非常长,因为MSG表的字段少说二十来个。不用怕,我们真正关心的只有其中几个字段。
2. 从数据库里把语音消息记录筛出来
2.1 MSG 表的关键字段
MSG表是微信本地消息的总账本,每条聊天记录占一行。对语音导出来说,最常用的字段是这些:
localId:本地自增 ID,不跨库保证唯一定位记录。MsgSvrID:服务器返回的消息 ID,全局唯一,后面匹配语音文件时有大用。Type:消息类型,语音固定是 34。IsSender:0 表示别人发给我的,1 表示我发出的。StrTalker:对端 wxid,群聊里存的是群 ID。CreateTime:Unix 时间戳,单位是秒。StrContent:文本内容,对语音消息来说通常为空或只有部分 XML。BytesExtra:二进制扩展字段,语音文件路径、语音长度、hash 这些信息经常藏在这里。
先按类型做个统计,确认语音消息确实有:
SELECT Type, COUNT(*) AS cnt FROM MSG GROUP BY Type ORDER BY cnt DESC;正常你会看到Type=34有一堆记录。如果一条都没有,那说明这台电脑上的语音根本没下载到本地,或者数据库版本字段结构完全不一样,先检查前面的目录路径有没有找对。
2.2 用 SQL 跑通第一个语音消息列表
直接在msg_0.db里跑这样一条查询,测试一下字段名对不对:
SELECT localId, MsgSvrID, IsSender, StrTalker, datetime(CreateTime, 'unixepoch', 'localtime') AS time_str, length(BytesExtra) AS extra_len FROM MSG WHERE Type = 34 ORDER BY CreateTime DESC LIMIT 20;如果这条 SQL 能正常返回,说明数据库结构是标准的。如果提示no such column: MsgSvrID或者no such column: BytesExtra,说明你手上的版本字段名做了调整,那就先执行.schema MSG把实际字段名抄出来,再对应替换。
注意datetime(...)那个函数是 SQLite 自带的,转换出来的是本地时间,方便人眼核对。实际批量导出时我更建议直接在 Python 里处理时间,别在 SQL 层做太多格式化,后面命名文件名可以更灵活。
2.3 写 Python 遍历所有 msg_*.db
单查一个分片肯定不够。我习惯把这些查询逻辑写成一个独立脚本,放在复制出来的分析目录里,对整个msg_*.db通配符做循环:
import sqlite3 import os import glob base_dir = r"D:\wechat_analysis" all_rows = [] for db_path in glob.glob(os.path.join(base_dir, "msg_*.db")): conn = sqlite3.connect(db_path) cur = conn.cursor() try: cur.execute(""" SELECT localId, MsgSvrID, IsSender, StrTalker, CreateTime, BytesExtra FROM MSG WHERE Type = 34 """) rows = cur.fetchall() except Exception as e: print(f"跳过 {db_path}: {e}") rows = [] conn.close() all_rows.extend(rows) print("语音记录总数:", len(all_rows))这一步跑通之后,你已经拿到了所有语音消息的“索引信息”。接下来最麻烦的问题来了:怎么根据这些索引找到真正的 .dat 语音文件。
3. 从数据库记录定位到真正的声音文件
3.1 别指望 StrContent 里直接给文件路径
很多人以为查到了语音消息,StrContent里就会写着C:\path\to\voice.dat。实际并不是这样。微信语音消息的StrContent大多为空,或者只写了一段没啥用的 XML,真正的文件信息在BytesExtra里,而且是一段二进制的序列化数据。
不同版本里这段二进制可能是缩略的 protobuf,也可能是自定义二进制结构。想纯靠手工解析字段不太现实,我一般先用blackboxprotobuf这种通用库暴力识别一下:
import sqlite3 import binascii import blackboxprotobuf conn = sqlite3.connect(r"D:\wechat_analysis\msg_0.db") cur = conn.execute("SELECT MsgSvrID, BytesExtra FROM MSG WHERE Type=34 LIMIT 5") for msg_svr_id, extra in cur.fetchall(): if extra is None: continue try: message, typedef = blackboxprotobuf.decode_message(bytes(extra)) print(msg_svr_id, message.keys()) except Exception: print(msg_svr_id, "解析失败") conn.close()blackboxprotobuf会把字节流里的字段按数字编号解析出来,字段号 1、2、3 这类就是 protobuf 的原始字段编号。微信版本更新以后,字段编号和类型经常变,所以你不能把解析结果写死,而是先打印出来看看里面有哪些看起来像 hash、路径、长度的值。
我在某个版本的BytesExtra里看到过一个 32 位的十六进制字符串,和 Voice 目录里的文件名正好对得上。这就是语音文件定位的关键线索。
3.2 用文件名 hash 做最粗暴的匹配
语音文件通常以.dat为后缀,文件名是一长串字母数字,长度多为 32 个字符,看着像 MD5。对应关系最稳的方法,就是把 Voice 目录下所有.dat文件的文件名全部建立一个索引,然后用数据库里挖出来的 hash 去撞:
import os def build_voice_index(voice_dirs): index = {} for d in voice_dirs: if not os.path.isdir(d): continue for root, _, files in os.walk(d): for f in files: stem = os.path.splitext(f)[0] index[stem] = os.path.join(root, f) return index voice_dirs = [ r"D:\wechat_analysis\Voice", r"D:\wechat_analysis\Voice2", r"D:\wechat_analysis\Msg\Multi\Voice", ] voice_index = build_voice_index(voice_dirs) print("语音索引数量:", len(voice_index))然后对每一条语音消息,把BytesExtra里所有长度为 32 的十六进制片段都提取出来,去voice_index里验证:
import re hex_pattern = re.compile(rb'[0-9a-fA-F]{32}') def find_voice_file(extra, voice_index): if not extra: return None for match in hex_pattern.findall(bytes(extra)): key = match.decode().lower() if key in voice_index: return voice_index[key] return None这个方法的逻辑很简单:数据库里只要出现过和文件名一样的 hash,基本就能认定是同一个语音文件。实测下来,它能解决大部分版本的定位问题,尤其是微信 3.7 和 3.9 这两个大版本。
3.3 兜底方案:按 MsgSvrID 猜文件名
如果BytesExtra里挖不出名字,还有一个笨办法:直接拿MsgSvrID去文件系统里猜。因为微信有些版本给媒体文件命名时,会直接用服务器消息 ID 做前缀或整体文件名。
import os def guess_by_msg_svr_id(msg_svr_id, voice_dirs): candidates = [ f"{msg_svr_id}.dat", f"{msg_svr_id}.silk", f"{msg_svr_id}.amr", f"voice_{msg_svr_id}.dat", f"msg_{msg_svr_id}.dat", ] for d in voice_dirs: for root, _, files in os.walk(d): for name in candidates: p = os.path.join(root, name) if os.path.exists(p): return p return None这个方案有点碰运气,但在老版本上偶尔能用。排错顺序我建议是:先走 hash 匹配,再走 MsgSvrID 匹配,两个都不行就把这条记录单独导出来,人工看一下BytesExtra的十六进制内容,重点找.dat、silk、voice这些 ASCII 字符串附近的数据。
一旦文件定位成功,后面的事情就简单多了。
4. SILK 解码与格式转换:三条路线任选
4.1 先看文件头,确认是不是 SILK
拿到.dat文件后,不要急着改后缀。微信语音文件虽然叫.dat,但内容基本是 SILK v3 编码的音频流。用xxd看一眼文件开头:
xxd 3f5a...dat | head如果前面出现了#!SILK_V3这样的纯文本签名,那么它就是一个标准的 SILK 文件,只是文件名后缀被换成了.dat。微信 PC 端常见采样率是 24000Hz,但这并不是绝对的,后面解码时如果音调不对,要记得换成 16000 试试。
如果文件头不是#!SILK_V3,而是一堆随机字节,有可能文件做了按字节 XOR 混淆。这种情况我在老版本上遇过一次,处理方法在后面“常见问题”里详细说。
4.2 路线一:Python 的 pilk 库直接解码
现在 Python 生态里最省事的是pilk这个库,它是 SILK v3 解码器的 Python 绑定,安装后可以直接把.dat转成 PCM 或者 WAV:
pip install pilk解码一个文件只需要两行代码:
import pilk pilk.decode(r"D:\wechat_analysis\3f5a...dat", r"D:\wechat_analysis\3f5a.wav", sample_rate=24000)pilk.decode的第三个参数指定采样率。如果声音出来后变快、变尖,说明采样率给高了,改成 16000 重新解码。如果文件报错说不是 SILK,那就回到 4.1 检查文件头。
这里要提醒一句:SILK 编码是有损压缩,解码成 WAV 后体积会膨胀不少。一分钟的语音,WAV 可能有三四 MB,而原始 SILK 可能只有几百 KB。所以后续打包成 MP3 时,可以在音质和体积之间取个平衡。
4.3 路线二:silk-v3-decoder 命令行工具
如果不想在 Python 环境里装依赖,或者pilk在你机器上编译失败,可以试试 SILK 官方源码翻出来的命令行版——silk-v3-decoder。下载源码后进入silk子目录编译:
git clone https://github.com/kn007/silk-v3-decoder cd silk-v3-decoder/silk make编译完会生成一个decoder之类的可执行文件,用法大致是:
./decoder input.silk output.wav这个工具的缺点是比较挑平台,在 Windows 上要么装 MSYS2,要么用项目里附带的其他工具链;在 Linux/macOS 上通常一次就能过。它输出的 WAV 可以直接喂给 ffmpeg 继续压 MP3。
4.4 路线三:ffmpeg 收尾压成 MP3
不管用 pilk 还是 silk-v3-decoder,拿到中间结果后,最终压制 MP3 我都会交给 ffmpeg。如果上一步直接生成了 WAV,命令行非常短:
ffmpeg -y -i 3f5a.wav -c:a libmp3lame -q:a 2 3f5a.mp3如果 pilk 只输出了裸 PCM 文件,那就需要显式告诉 ffmpeg 采样率、位深和声道数。微信语音几乎都是单声道、16bit、PCM 小端序,所以命令长这样:
ffmpeg -y -f s16le -ar 24000 -ac 1 -i 3f5a.pcm \ -c:a libmp3lame -q:a 2 3f5a.mp3-f s16le表示输入格式是 16bit 有符号小端 PCM,-ar 24000是采样率,-ac 1是单声道。这行命令里采样率一定要和 pilk 解码时用的一致,否则压出来的 MP3 依然会是变调的。
4.5 给批量导出写一个完整的处理流程
单文件跑通后,批量就水到渠成了。我一般会把整个过程串成一个脚本:读取数据库筛选语音记录 → 定位 .dat 文件 → pilk 解码成 WAV → ffmpeg 压成 MP3 → 按时间重命名。
核心循环如下:
import os import subprocess import pilk def convert_one(src_dat, out_mp3, sample_rate=24000): wav_path = out_mp3 + ".wav" pilk.decode(src_dat, wav_path, sample_rate=sample_rate) subprocess.run([ "ffmpeg", "-y", "-i", wav_path, "-c:a", "libmp3lame", "-q:a", "2", out_mp3 ], check=True) os.remove(wav_path)对每一行语音记录调用这个函数时,建议在out_mp3文件名里带上CreateTime、StrTalker、IsSender,比如:
20240512_193000_wxidabc_send.mp3 20240512_193100_wxiddef_recv.mp3这样导出几百个文件后,依然能按时间轴和聊天对象快速找到想要的那条语音。
5. 常见问题与排查技巧
5.1 数据库打不开,提示 file is not a database
这是最常见的翻车点。先看文件头是不是SQLite format 3。如果不是,多半是遇到了 SQLCipher 加密版本。加密数据库用普通 sqlite3 打不开,需要对应的 key 才能解密。
处理这类问题我的原则是:优先用微信官方备份功能,把聊天记录先备份到手机再恢复一次,从备份产物里拿数据。不要一上来就去进程内存里翻数据库密钥,那条路虽然可行,但涉及客户端逆向,风险高、版本依赖强,而且很容易把安全问题放大。本篇文章只覆盖能直接打开的明文数据库场景。
5.2 一条语音消息都筛不出来
先确认Type字段是不是 34。不同版本偶尔会把语音 AppMsg 消息的 Type 记成 49,子类型里才写 34。如果你发现语音消息一条都查不到,把Type=49的记录也拉出来看看,它的StrContent里通常有一段<voicemsg>开头的 XML,里面也会出现voicemd5这样的字段。
5.3 文件头不是 #!SILK_V3,全是乱码
这是老版本微信一个很坑的细节:部分语音.dat文件做过整文件异或混淆,把每一个字节都和固定 key 异或了一遍。判断方法很简单:读前 16 个字节,对从 0 到 255 的每个单字节 key 逐一试,看能不能还原出#!SILK_V3签名:
data = open("voice.dat", "rb").read() target = b"#!SILK_V3" for key in range(256): head = bytes([b ^ key for b in data[:9]]) if head == target: print("找到 XOR key:", hex(key)) restored = bytes([b ^ key for b in data]) open("voice_restored.silk", "wb").write(restored) break上面这段代码我实际救回过一批老语音文件。还原后再用前面的 pilk 方案解码即可。当然,这个 key 不一定总是同一个值,所以写成循环扫描最稳妥。
5.4 解码后声音变调或者时长不对
判断标准很简单:人声变尖、语速变快,就是采样率设高了;人声变闷、语速变慢,就是采样率设低了。微信 PC 端常见的是 24000,但部分语音可能来自旧手机备份,实际是 16000 甚至 8000。先拿单条语音多试几组采样率,确定了再跑批量。
5.5 匹配不到文件,但数据库里明明有记录
这种情况往往是语音消息没有在本地完整下载。微信为了省空间,会对很久以前的语音做“过期清理”,只保留数据库里的元信息,不保留音频文件本体。另一个可能是当天语音还停留在“未下载”状态,需要先在手机上点开播放一次,再重新登录 PC 端同步。
如果实在找不到,还有一个歪门邪道:去Msg目录下属的File、Image、Video等目录里全局搜.dat文件名,因为语音文件可能被微信挪到别的缓存目录。全盘搜一次也没有,那就是真没下载下来了。
6. 我自己的几个实操习惯
这套流程跑顺之后,我把部分逻辑固定成了一个小工具,每次微信提示存储空间不足时,先把语音批量导出到 NAS,再从手机上清理。整个过程我最在意的是三件事。
第一,永远只在复制出来的目录上做分析。微信客户端只要开着,就会持续写数据库,直接连到正在运行的msg_*.db上,轻则查询结果不一致,重则把数据库搞坏。现在我的操作顺序永远是“退出微信 - 复制整个 Multi 目录 - 在副本上跑脚本”。
第二,原始.dat文件先保留一份,导出 MP3 之后不要急着删。MP3 是有损格式,万一后面想转成更高码率或者做语音转写,原始文件才是最可靠的材料。等确认整批语音都完好无误后,再考虑清掉原始数据。
第三,命名信息要够全。一个小技巧是先把语音记录写入 CSV,把MsgSvrID、CreateTime、StrTalker、源文件路径都保留下来,然后再逐条转换。这样万一某条语音转换失败,还能从 CSV 里反查重试,不用把整个数据库再扫一遍。
微信本地语音导出这件事,技术上并不复杂,真正的门槛在于不同版本之间的字段和存储差异。拿到一个新版本时,不要急着跑批量脚本,先用.schema MSG和.tables把结构看清楚,再花两分钟确认一个语音文件的真实编码,后面基本就是流水线操作了。希望这篇实战记录能帮你少踩几个坑,顺利把聊天记录里的声音都搬回家。