简介:面向macOS开发者的QQ音乐QMC加密格式批量转换工具源码,完整覆盖qmcflac/mflac/mflac0转FLAC、qmc0/qmc3转MP3等主流转换链路,可直接用于课程设计、学期综合训练或毕业设计,也适合初学者熟悉Swift语言与加解密算法在真实项目中的落地方式。资源包共43个文件,压缩后约981KB,核心代码以Swift源码为主,另含png、gif等界面素材、plist与json配置文件、storyboard界面描述以及Xcode工程组织文件,目录结构清晰,便于按模块阅读和二次重构。目前已有121人学习浏览,可作为macOS音频处理与格式逆向的小型项目参考。包内除可运行的Xcode工程外,还附带README说明、License授权信息及多份备份文件,能帮助读者快速理解QMC解码流程与Tea加密算法的Swift实现细节,为后续扩展更多音频格式或迁移至其他平台提供基础。
1. macOS 上的 QMC 批量转码:一份能直接编译运行的 Swift 工程
QMC 加密格式转码这个需求,在 macOS 上一直有点尴尬:QQ 音乐缓存的 qmcflac、mflac 后缀文件,Windows 下有现成的批量工具,换到 macOS 上就得自己找脚本或者绕道虚拟机。这份资源是一个原生 macOS 工程,Xcode 打开就能跑,拖入 qmcflac/mflac 输出 FLAC,拖入 qmc0/qmc3 输出 MP3,批量、进度、输出目录都直接安排好了,适合日常自用,也适合拿来做毕设的音频格式解析参考。
工程主体是 Swift 写的四个解码模块加界面层:密钥读取、TEA 解密、流式解密、顶层封装,结构比网上那些几百行的脚本完整得多。你要有点 Swift 基础,至少能看懂 Data 操作和 FileManager,不然调参数会吃力。下面按我拆解这份工程的顺序来:先讲格式原理,再讲代码模块,最后放进 Xcode 跑起来。
2. QMC 解密链路与格式选型:TEA 算法、密钥表、按内层格式决定输出
2.1 QMC 不是单一格式:四类后缀对应两种内层编码
很多人一看到 qmcflac、mflac、qmc0、qmc3 就以为这是四种不同的加密算法,其实它们只是 QQ 音乐本地缓存时按原始音源格式加的不同后缀。解密之后的内层数据就两类:FLAC 和 MP3。后缀与内层格式的对应关系是固定的,资源包里的 QMDecoder 也是按这张表来分发处理逻辑的:
| 原始后缀 | 内层实际格式 | 解密后建议输出 | 典型来源 |
|---|---|---|---|
| qmcflac | FLAC | .flac | 无损音质缓存 |
| mflac / mflac0 | FLAC | .flac | 新版无损缓存 |
| qmc0 | MP3 | .mp3 | 标准音质缓存 |
| qmc3 | MP3 | .mp3 | 较早版本缓存 |
之所以强调“按内层格式决定输出扩展名”,是因为有相当一部分转换工具只按后缀猜测输出格式,遇到 mflac0 这种带数字的后缀就懵了,直接输出成 .mflac0 或者干脆报错。正确做法是先解密文件头、识别内层魔数(FLAC 的fLaC,MP3 的ID3或帧同步字节),再决定输出路径的后缀。这一点在资源包的 QMDecoder.swift 里是作为顶层判断逻辑存在的,而不是简单字符串截取。
QMC 格式本身的结构也不复杂:文件头部有一段密钥相关信息(索引、掩码),后面跟着被 TEA 加密的音频数据流。密钥表对使用者来说是个黑匣子,它被固化在 Constants.swift 的大数组里——这也是为什么网上的方案通常要带着整个密钥表一起分发,缺了它,算法再对也解不出来。
2.2 解密链路:从文件头索引到 TEA 解密再到异或还原
QMC 这类本地加密容器的解密流程,在这份工程里的实现可以拆成五步,每一步都有对应的函数入口:
- 读取文件前 0x10 字节(16 字节头部),从固定偏移取出密钥索引值,常见是小端序 UInt32。
- 用索引去 Constants.swift 的密钥表里查 16 字节密钥;索引超出表长时,要回退到备用偏移重新取索引。
- 用 TEA 算法对头部加密块做解密,得到初始化解密状态。
- 按 0x800 字节(2048 字节)一块的大小循环读取文件剩余部分,对每个块做流式异或还原。
- 解密后的数据以
fLaC或 MP3 帧同步开头时,认定解密成功,按内层格式写入输出文件。
第五步不是可选项。我见过太多转换脚本把“解密完”和“解密成功”混为一谈,输出了一堆野数据还不报错。这个工程的做法是解密完立刻校验头部魔数,校验不过就中断并提示,这个习惯值得保留。
TEA 部分的参数需要说清楚:TEA(Tiny Encryption Algorithm)是对 64 位数据块做加密,密钥长度 128 位(16 字节),常用的常量 delta 是 0x9E3779B9。标准实现跑 32 轮,部分移植版本为了省循环次数写成 16 次循环(一次循环里做两次迭代),解密结果一致,但你在看别人代码时容易被这个数字差异带偏。资源包里的 TeaCipher.swift 是把轮数和 delta 都抽成常量的,改起来不费劲。
2.3 输出格式决策:为什么必须“先解密再定扩展名”
这里有个工程上的细节:解密器拿到一个 QMC 文件时,不能只凭后缀决定输出 .flac 还是 .mp3,原因有两个。
第一,qmcflac 和 mflac 虽然内层都是 FLAC,但它们的加密头长度和密钥索引位置不完全一样,mflac0 还要额外处理版本标记。如果写死“后缀含 flac 就按 FLAC 解”,遇到新变种就会出错。
第二,解密后的内层格式是可以通过魔数判断的,这个判断比后缀可靠得多。FLAC 文件的前 4 个字节固定是fLaC,MP3 则有ID3标签头或者0xFF 0xFB这类帧同步字节。工程在解密完第一个数据块后立刻做这个嗅探,嗅探结果决定最终扩展名和后续处理参数(比如 FLAC 不需要处理 ID3 标签,MP3 要保留 ID3v2 的标签长度字段)。
我一般会把这种嗅探逻辑写在单独的函数里,而不是散落在解密循环里,方便以后扩展新后缀。资源包里的 QMDecoder.swift 就是这么组织的——扩展名映射只负责兜底,魔数嗅探才是一锤定音。
3. Swift 核心模块拆解:QMCKeyDecoder、TeaCipher、QMCipher 各管哪一段
3.1 四个文件的分工与调用关系
解码相关的核心文件就四个,职责边界很清晰,这也是这份源码适合当毕设参考的原因——它不是一个堆在单个文件里的算法脚本,而是一个有分层意识的工程。先把分工列出来:
| 文件 | 职责 | 对外接口 |
|---|---|---|
| QMCKeyDecoder.swift | 读文件头、解析密钥索引、从密钥表取密钥 | func key(from header: Data) -> Data? |
| TeaCipher.swift | TEA 加解密算法实现 | func decrypt(_ data: Data, key: Data) -> Data |
| QMCipher.swift | 按块解密整个文件数据流,调用 TeaCipher | func decryptFile(_ url: URL) throws -> Data |
| Constants.swift | 密钥表常量、块大小、偏移量定义 | 无,纯数据 |
调用关系是单向的:QMCipher 依赖 QMCKeyDecoder 拿密钥,依赖 TeaCipher 做块解密,QMDecoder 再把它们串起来对上层的 ViewController 暴露“一个文件转成另一个文件”的能力。这种分层的好处是,你想换算法版本时只需要替换 QMCKeyDecoder 里的索引解析逻辑,不需要动界面代码。
工程里还有 QMCDecodeTests.swift 这个单元测试文件,里面应该有对 TEA 解密和密钥索引的测试用例。跑测试是验证解密器有没有被改坏的最快方式,尤其是你改了 Constants.swift 里的密钥表之后——表错一个字节,转出来的全是一堆噪声音频。
3.2 QMCKeyDecoder:密钥索引的偏移量计算细节
密钥索引解析是整个解密流程里最容易翻车的地方。QMC 不同版本的头部结构不一样,早期版本直接在前 0x10 字节里放索引,新版 mflac 则可能在头部异或掩码之后再取索引。这类工程里常见的实现思路是这样的(伪代码级 Swift,核心逻辑与资源包一致):
import Foundation struct QMCKeyDecoder { static func keyIndex(from header: Data) -> Int? { // 头部不足 16 字节,没法解析索引,直接放弃 guard header.count >= 16 else { return nil } // 常见做法:取前 4 字节小端序作为候选索引 // 但有些版本会用异或掩码做一次修正 let candidate = header.withUnsafeBytes { raw -> UInt32 in raw.loadUnaligned(as: UInt32.self) } let idx = Int(candidate) // 索引超界时,尝试从偏移 0x7C 处读备用索引 if idx >= QMCKeyTable.count, header.count >= 0x80 { let backup = header.subdata(in: 0x7C..<0x80) .withUnsafeBytes { $0.loadUnaligned(as: UInt32.self) } return Int(backup) } return idx } static func teaKey(for header: Data) -> Data? { guard let idx = keyIndex(from: header), idx < QMCKeyTable.count else { return nil } return QMCKeyTable[idx] } }这里的loadUnaligned(as:)是 Swift 5 之后推荐的读取方式,可以直接从 Data 的原始字节里按小端序读 UInt32,不用手工算偏移。需要注意header.subdata(in: 0x7C..<0x80)是闭区间切割,取的是第 0x7C 到 0x7F 四个字节,如果你改成0x7C...0x80就会越界崩溃。
备用偏移 0x7C 不是所有版本都有,它是为 mflac 这套新索引结构准备的。实际调试时如果遇到索引超出密钥表长度,不要急着改密钥表,先确认是不是该走备用索引分支。
3.3 TeaCipher:16 轮迭代与 delta 常量
TEA 解密本身不复杂,网上能找到各种语言版本,但移植到 Swift 时有两个容易踩的细节:无符号整数溢出和数据块对齐。解密循环里大量用到&+、&-、&^这类溢出运算符,直接写+在 Release 模式下可能因为溢出崩溃。下面是标准的 TEA 解密块实现:
import Foundation struct TeaCipher { static let delta: UInt32 = 0x9E3779B9 /// 解密一个 8 字节块,key 必须是 16 字节 static func decryptBlock(_ v0: UInt32, _ v1: UInt32, key: [UInt32]) -> (UInt32, UInt32) { var y = v0, z = v1 var sum: UInt32 = 0xC6EF3720 // delta << 5,32 轮初始和 // TEA 标准 32 轮;有些工程写成 16 次循环,效果等价 for _ in 0..<32 { z &-= ((y << 4) &+ key[2]) ^ (y &+ sum) ^ ((y >> 5) &+ key[3]) y &-= ((z << 4) &+ key[0]) ^ (z &+ sum) ^ ((z >> 5) &+ key[1]) sum &-= delta } return (y, z) } static func decrypt(_ data: Data, key: Data) -> Data { precondition(key.count == 16, "TEA 密钥必须为 16 字节") var keyWords = key.withUnsafeBytes { raw in Array(raw.bindMemory(to: UInt32.self)) } // 小端序加载后,把 UInt32 数组按主机字节序解释 keyWords = keyWords.map { $0.littleEndian } var result = Data() let blocks = data.count / 8 for i in 0..<blocks { let start = i * 8 let block = data.subdata(in: start..<start + 8) let a = block.withUnsafeBytes { $0.loadUnaligned(as: UInt32.self) } let b = block.withUnsafeBytes { $0.loadUnaligned(as: UInt32.self, offsetBy: 4) } let (d0, d1) = decryptBlock(a, b, key: keyWords) var out = [d0.littleEndian, d1.littleEndian] result.append(Data(bytes: &out, count: 8)) } return result } }简单说几个参数:0xC6EF3720是delta左移 5 位的值,也就是 32 轮累计的 delta 总和,解密时从它开始递减,加密时则从 0 开始递增。key按 UInt32 每 4 字节切分成 4 个 32 位字,分别参与两条运算链。注意littleEndian的转换:文件里读出来的字节序和内存里的主机字节序不一定一致,跳过这一步,在 Intel Mac 上可能碰巧能跑,换到 Apple Silicon 上就会翻车。
末尾不足 8 字节的数据块,TEA 处理不了,需要保留原样拼到输出尾部。QMC 文件的数据流通常按块对齐了,但你不能赌它一定对齐,所以我一般会在解密函数里显式处理尾部余数。
3.4 QMCipher:流式解密与块大小对齐
QMCipher 是这个工程里的“粘合层”,它把密钥解析和 TEA 解密串成一条流水线,并且负责分块读取,避免一次性把整个大文件载入内存。常见的实现方式是:
import Foundation import CryptoKit struct QMCipher { static let blockSize = 0x800 // 2048 字节,解密处理单位 static func decryptFile(at url: URL, key: Data) throws -> Data { let handle = try FileHandle(forReadingFrom: url) defer { try? handle.close() } var out = Data() // 先读头部,解析密钥,再用头部数据做解密初态 let header = try handle.read(upToCount: 0x10) ?? Data() guard let teaKey = QMCKeyDecoder.teaKey(for: header) else { throw QMCDecodeError.keyNotFound } // 跳过已经读过的头部,从加密数据起始处开始按块解 try handle.seek(toOffset: UInt64(header.count)) while let chunk = try handle.read(upToCount: blockSize), !chunk.isEmpty { let decrypted = TeaCipher.decrypt(chunk, key: teaKey) out.append(decrypted) } return out } }分块读取用FileHandle.read(upToCount:)而不是Data(contentsOf:),是因为 Hi-Res 音源一首歌可能就是一两百 MB,一次性读进来在批量场景下内存会迅速爆掉。块大小取 0x800 不是随意的,这是 QMC 加密的最小操作单位,解密函数按这个粒度处理时性能和成功率都最稳。
这段代码里我故意没写尾部余数处理,实际工程里必须在while循环之后补上:
if let tail = try handle.readToEnd(), !tail.isEmpty { out.append(tail) // 不足一块的数据不参与 TEA,原样保留 }尾部数据直接 append 看起来有点玄学,但它能保住最后几毫秒的音频不丢,实测下来比强行补零再解密靠谱。你如果发现转出来的文件末尾有杂音,优先检查这一块的处理逻辑。
4. 把工程跑起来:Xcode 编译、批量转换流程与工程文件取舍
4.1 工程文件清单:哪些文件能删、哪些文件别动
资源包解压之后,目录里除了 QMCDecode.xcodeproj 还有一堆看起来莫名其妙的文件和文件夹。先分清主次,免得误删核心文件:
| 路径 | 用途 | 是否核心 |
|---|---|---|
| QMCDecode.xcodeproj | Xcode 工程文件,双击打开 | 核心 |
| QMCDecode/QMCKeyDecoder.swift | 密钥索引解析 | 核心 |
| QMCDecode/TeaCipher.swift | TEA 算法 | 核心 |
| QMCDecode/QMCipher.swift | 流式解密 | 核心 |
| QMCDecode/QMDecoder.swift | 顶层封装、魔数判断 | 核心 |
| QMCDecode/Constants.swift | 密钥表与常量 | 核心,别手贱去改 |
| QMCDecode/ViewController.swift | 拖拽与批量界面逻辑 | 核心 |
| QMCDecode/WindowController.swift | 窗口行为 | 辅助 |
| QMCDecode/Info.plist | 应用配置、文件类型声明 | 辅助 |
| QMCDecodeTests/ | 单元测试 | 建议保留 |
| example.gif | 使用演示动图 | 可删 |
| 备份文件.zip / *.zbak | 备份文件 | 可删,不影响编译 |
带.zbak后缀的文件是备份文件,Xcode 编译时不会引用它们,但你要是用“在文件夹中搜索”的方式找文件,很容易被这些备份干扰。建议直接删掉.zbak和根目录的备份文件.zip,只留工程本体。
4.2 编译与首次运行:签名弹窗、Gatekeeper 与 xattr 清理
打开 QMCDecode.xcodeproj 后,选择 macOS 作为目标平台,直接 Cmd+R 跑。首次编译如果报签名相关错误,先到 Signing & Capabilities 里把 Team 改成自己的 Apple ID(或者选 Sign to Run Locally)。这个工程带了一个 QMCDecode.entitlements 文件,说明它声明了某些沙箱权限,在本地调试时不用管,打包分发时才需要按证书调整。
编译成功后首次运行会撞上 Gatekeeper 的经典问题:如果你是从网上下载的工程,系统会提示“无法验证开发者”。这不是代码问题,是 macOS 对未签名应用的隔离机制。常见的解决方式有两种,我一般用命令行处理,因为快:
# 去掉下载隔离属性,然后再打开 App xattr -dr com.apple.quarantine /Applications/QMCDecode.app如果 App 还没拷到 Applications 目录,直接对 build 产物执行同样的命令也行。注意xattr -dr会把目录下所有文件的 quarantine 属性都删掉,别手滑对某个重要目录执行。
另一种方式是在 Finder 里右键 App 图标选“打开”,然后在弹窗里点“打开”——这个操作只对当前 App 生效一次,下次还是可能被拦。For 批量使用场景,还是 xattr 一步到位更省心。
4.3 批量转换的实际操作:拖拽、输出目录与进度反馈
资源包的 ViewController 实现了拖拽和批量处理逻辑,玩法是:打开 App 后,把若干 QMC 文件直接拖进窗口,选择输出目录,点转换。核心流程在代码里是一个 forEach 循环,逐个文件走“读取 → 解密 → 魔数嗅探 → 写文件 → 更新进度”的链路。批量部分的关键点是避免在主线程里做耗时的文件 I/O,否则窗口会卡死:
DispatchQueue.global(qos: .utility).async { for (index, url) in inputURLs.enumerated() { do { let data = try QMDecoder.decode(url) let ext = QMDecoder.outputExtension(for: url) let outName = url.deletingPathExtension() .appendingPathExtension(ext).lastPathComponent let outURL = outputDir.appendingPathComponent(outName) try data.write(to: outURL, options: .atomic) DispatchQueue.main.async { progressLabel.stringValue = "\(index + 1)/\(inputURLs.count)" } } catch { failedURLs.append(url) } } DispatchQueue.main.async { progressLabel.stringValue = "完成,失败 \(failedURLs.count) 个" } }这段代码的逻辑不复杂,但有两个参数值得关注:options: .atomic保证写入要么完整成功要么不动原文件,不会出现写了一半断电导致文件名存在但内容损坏的情况;进度更新放在主线程是因为 AppKit 的 UI 操作必须回主线程,放在子线程会看到界面不刷新甚至崩溃。
输出文件名的处理用的是deletingPathExtension+appendingPathExtension,比直接字符串替换稳妥,能正确处理song.qmcflac和song.mflac0这种带了数字的后缀。
4.4 做毕设演示时的一个额外建议:日志输出到文件
如果你拿这份工程做课程设计或毕设,我强烈建议加一个日志模块:每次转换完成,把源文件路径、输出路径、文件大小、耗时、校验结果写到一个 UTF-8 的文本文件里。原因很简单,答辩时老师大概率会问“你怎么证明转换是正确的”,光靠播放器放一遍没有说服力,而日志里的文件大小对比和魔数校验结果是可以直接展示的证据。
实现上不用引入第三方库,用一个FileHandle在追加模式写就能搞定:
let logHandle = FileHandle(forWritingAtPath: logURL.path) logHandle?.seekToEndOfFile() logHandle?.write(entry.data(using: .utf8)!)日志条目里至少包含三列:源文件大小、输出文件大小、是否通过魔数校验。QMC 解密后的数据理论上和源音频等长(忽略头部差异),所以源文件大小与输出文件大小差太多,基本可以判定解密出了问题。
5. QMC 批量转换避坑指南:五个高频坑的现象、原因与解决
5.1 转出的 FLAC 播放器不认:解密成功但文件头残留填充数据
现象:解密流程跑完了,文件也写出来了,file命令也确实显示 FLAC,但 QuickTime 和部分播放器直接拒绝播放,VLC 能打开却全是嘶嘶声。
原因:QMC 加密时会在文件头部附加一段填充数据,解密后这段数据变成无意义的字节。只看魔数fLaC判断解密成功远远不够,FLAC 的 STREAMINFO 必须位于文件的开头,前面一旦有杂质,严格校验的播放器就会拒播。
解决:解密后不要立刻写文件,先扫描解密数据从第几个字节开始出现fLaC魔数,把前面的填充字节全部丢弃,再写文件。我一般会把这个“魔数定位”逻辑做成一个工具函数,输出前强制调用一遍,一劳永逸。
5.2 批量输出同名覆盖:第二次转换把第一次结果直接抹掉
现象:批量转换 50 首歌,最后在输出目录里只找到 20 个文件,而且每个大小都异常地大——仔细一看,是同名歌曲互相覆盖,最后的文件把前面的覆盖了。
原因:QQ 音乐缓存的多张专辑里经常出现track01.flac这种同名文件,而批量逻辑里用lastPathComponent做输出文件名,没有拼上级目录,也没有做重名检测。
解决:在写输出文件之前先判断目标路径是否存在,存在则在文件名后面加_1、_2这样的序号。更彻底的做法是保留源文件在 QQ 音乐缓存目录里的相对路径结构,输出时按歌手/专辑/歌曲.后缀重建目录。
5.3 Gatekeeper 拦截:双击 App 提示“已损坏”
现象:从网络下载的 QMCDecode 编译产物或打包好的 App,双击提示“无法打开,因为无法验证开发者”或者“已损坏,无法打开”,换台电脑也一样。
原因:macOS 的 Gatekeeper 对没有 Developer ID 签名的应用一律拦截,而网上下载的文件还带上了 quarantine 扩展属性。“已损坏”这个提示极具误导性,实际上文件的二进制完好,只是隔离属性没清除。
解决:在终端执行xattr -dr com.apple.quarantine /path/to/QMCDecode.app,然后再双击。如果还不行,检查 App 是不是在磁盘映像里没拷出来,直接从 dmg 里运行也会被拦,拷到本地目录再处理。
5.4 qmc0 全转成 MP3 但全是空白:密钥表版本对不上
现象:qmcflac 转换一切正常,但一批 qmc0 文件转出的 MP3 打开后是几秒钟的空白或者直接报错,没有任何音频数据。
原因:qmc0 是较早的加密版本,它的密钥索引规则和 qmcflac 不完全一致。如果工程内置的密钥表是针对新版格式生成的,老版本文件的索引查出来是错的,TEA 解密出来的自然是垃圾数据。
解决:在 QMCKeyDecoder 里为 qmc0/qmc3 单独走一套索引解析逻辑,或者干脆在解码前检查文件头里的版本标记。最稳妥的做法是解不出来就报错,而不是输出一个看起来像 MP3 的坏文件——坏文件比没文件更坑人,因为你很难察觉。
5.5 大文件批量转换崩溃:一次性读入整文件的内存问题
现象:转换普通歌曲没毛病,但遇到 96kHz/24bit 的 Hi-Res FLAC(一首歌两百多 MB),转到一半 App 卡死,然后被系统杀死。
原因:解码器里如果有Data(contentsOf:)一次性读取整个文件的路径,内存峰值会达到文件大小的好几倍——解密过程要同时持有源数据、解密中间数据和输出数据,两百 MB 的源文件能撑爆几个 GB 的内存。
解决:检查解密循环是否用块读取替代了整读。把Data(contentsOf:)换成FileHandle按 0x800 字节分块处理,内存占用能压到几十 MB 级别。另外一个容易被忽略的点:批量转换时不要在循环里保存所有输出 Data,处理完一首写一首,写完后立即释放。
6. 进阶:用 30 行 Python 脚本验证转出来的文件,不靠耳朵下结论
6.1 为什么文件大小一样但打不开:验证要看魔数
QMC 转码有个“玄学”,解密器报成功、文件大小也合理,但播放器就是打不开。原因在于解密后的数据即使开头几个字节碰巧对了,后面的数据可能仍然是乱的——单靠“能不能播放”来判断转换质量,既不精确又费时间。正确做法是写一个验证脚本,对每个输出文件检查两件事:文件头魔数是否符合格式预期,文件大小是否与源 QMC 文件在合理范围内一致。
6.2 一个 30 行的验证脚本:魔数检查加文件大小对比
下面这个 Python 脚本不依赖任何第三方库,用标准库就能跑,适合在批量转换后做一次全量巡检。把输出文件路径作为参数传进去,它会对每个文件做魔数嗅探,并输出文件大小,方便你对照源文件排查。
import os import sys def sniff_header(path): """读取文件前 16 字节,返回格式识别结果""" with open(path, 'rb') as f: head = f.read(16) if head[:4] == b'fLaC': return 'FLAC' if head[:3] == b'ID3': return 'MP3 (ID3 tag)' if len(head) >= 2 and head[0] == 0xFF and (head[1] & 0xE0) == 0xE0: return 'MP3 (frame sync)' return 'UNKNOWN' def main(): if len(sys.argv) < 2: print('用法: python3 verify_qmc_output.py <文件或目录>') sys.exit(1) targets = [] for arg in sys.argv[1:]: if os.path.isdir(arg): for root, _, files in os.walk(arg): targets += [os.path.join(root, f) for f in files] else: targets.append(arg) for path in sorted(targets): fmt = sniff_header(path) size = os.path.getsize(path) flag = 'OK' if fmt != 'UNKNOWN' else 'FAIL' print(f'{flag:4s} {fmt:20s} {size:10d} {os.path.basename(path)}') if __name__ == '__main__': main()这个脚本的判定逻辑很简单:FLAC 认fLaC四字节魔数;MP3 优先认ID3标签头,没有标签时认帧同步字节(第一字节0xFF,第二字节高三位为111)。它不会误伤任何合法的 FLAC 或 MP3 文件,而解密不完整的文件通常会在前 16 字节里暴露出问题。
文件大小对比则需要在转换前记下源 QMC 文件的大小,然后对比输出文件大小。QMC 解密后的数据应当与原音频等长,所以源文件大小减去加密填充的头部长度,应该约等于输出文件大小。如果差得离谱,说明解密过程丢数据了。把脚本跑完,看到所有文件都是 OK,才算真正收工。
从那以后我每次批量转完,都会强制让这个脚本在整个输出目录上走一遍,再谈播放和分发的事。这一步帮我挡掉过至少三次“转出来是坏的”的沟通成本,已经成了固定习惯。希望帮到你。
本文还有配套的精品资源,点击获取