WAV 逆向分析工具使用笔记

记录一下这个工具是怎么来的、怎么用、以及它帮我发现了什么问题。主要给自己看的备忘。


一、为什么要写这个工具

项目里有一段音频转换脚本,看起来没问题,但仔细一看逻辑是矛盾的:

复制代码
need = (w.getframerate() != 22050 or
        w.getsampwidth() != 2   or
        w.getnchannels() != 1)          # 判断:目标是 22050 / 16bit / 单声道
if need:
    cmd = ["ffmpeg", "-y", "-i", in_file,
           "-ar", "44100", "-ac", "2", "-sample_fmt", "s16",
           "-f", "wav", out_file]       # 实际:转成 44100 / 立体声

判断要 22050/单声道,命令却做 44100/立体声。但奇怪的是,生成的音频都能正常播放。

为了搞清楚文件到底是谁生成的、有没有被转换过,就写了这个工具。


二、工具原理(一句话)

WAV 是未压缩的 PCM 容器,文件头里明写着采样率、声道数、位深 ,ffmpeg 转码还会留下 LavfXX.XX 标记。所以只要读文件头,就能反推:

  • -ar → fmt.dwSamplesPerSec

  • -ac → fmt.nChannels

  • -sample_fmt → fmt.wBitsPerSample

  • 是否被 ffmpeg 处理过 → ISFT 字段里有没有 Lavf


三、程序运行截图

📷 图 1:主界面

  • 顶部:选文件夹、递归勾选、开始分析

  • 中间:表格显示分析结果

  • 底部:状态栏 + 进度条


📷 图 2:分析结果示例

判定结果分四种:

判定 含义
✅ A: 44100/2ch 由 44100 命令实际转码
✅ B: 22050/1ch 由 22050 命令实际转码
⚠ B(未转) 符合目标格式,但没有 Lavf 标记 → 根本没被转换过
❓ 其他 其他格式(如 16000 Hz)

📷 图 3:导出 Excel 报告

导出包含 13 列:文件名、采样率、声道、位深、时长、大小、ISFT、ffmpeg 标记、判定、建议命令、完整路径等。中文不乱码,冻结首行,方便归档。


四、程序完整代码

python 复制代码
# wav_forensics_gui.py
# WAV 逆向分析工具:扫描文件夹内 .wav,判定由哪条 ffmpeg 命令生成
# 依赖:Python 3.8+(标准库 tkinter);Excel 导出可选装 openpyxl
# 运行:python wav_forensics_gui.py

import os
import struct
import csv
import subprocess
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
from datetime import datetime

try:
    from openpyxl import Workbook
    HAS_OPENPYXL = True
except ImportError:
    HAS_OPENPYXL = False


# ==================== WAV 解析与判定 ====================

def parse_wav(path):
    """解析 RIFF/WAVE,提取 fmt / LIST-INFO / data"""
    with open(path, 'rb') as f:
        data = f.read()

    if len(data) < 12 or data[:4] != b'RIFF' or data[8:12] != b'WAVE':
        raise ValueError("不是合法的 WAV 文件")

    result = {"chunks": [], "fmt": None, "info": {}, "data_size": 0}
    pos = 12
    while pos + 8 <= len(data):
        cid = data[pos:pos + 4]
        csize = struct.unpack('<I', data[pos + 4:pos + 8])[0]
        body = data[pos + 8:pos + 8 + csize]
        result["chunks"].append(cid.decode('ascii', 'replace'))

        if cid == b'fmt ' and len(body) >= 16:
            af, ch, sr, br, ba, bits = struct.unpack('<HHIIHH', body[:16])
            result["fmt"] = {
                "audio_format": af, "channels": ch, "sample_rate": sr,
                "byte_rate": br, "block_align": ba, "bits_per_sample": bits
            }
        elif cid == b'LIST' and body[:4] == b'INFO':
            p = 4
            while p + 8 <= len(body):
                sid = body[p:p + 4]
                ssize = struct.unpack('<I', body[p + 4:p + 8])[0]
                sval = body[p + 8:p + 8 + ssize].rstrip(b'\x00')
                result["info"][sid.decode('ascii', 'replace')] = \
                    sval.decode('utf-8', 'replace')
                p += 8 + ssize + (ssize & 1)
        elif cid == b'data':
            result["data_size"] = csize

        pos += 8 + csize + (csize & 1)
    return result


def analyze_wav(path):
    """返回结构化 dict,用于表格 / 导出"""
    size_kb = round(os.path.getsize(path) / 1024, 1)
    try:
        info = parse_wav(path)
    except Exception as e:
        return {
            "path": path, "name": os.path.basename(path),
            "sr": "", "ch": "", "bits": "", "codec": "",
            "dur": "", "size_kb": size_kb, "isft": "", "lavf": "",
            "verdict": f"❌ 解析失败:{e}", "command": ""
        }

    fmt = info["fmt"]
    if not fmt:
        return {
            "path": path, "name": os.path.basename(path),
            "sr": "", "ch": "", "bits": "", "codec": "",
            "dur": "", "size_kb": size_kb, "isft": "", "lavf": "",
            "verdict": "❌ 未找到 fmt chunk", "command": ""
        }

    sr = fmt["sample_rate"]; ch = fmt["channels"]; bits = fmt["bits_per_sample"]
    is_pcm = fmt["audio_format"] == 1
    dur = info["data_size"] / fmt["byte_rate"] if fmt["byte_rate"] else 0
    isft = info["info"].get("ISFT", "")
    has_lavf = ("Lavf" in isft) or ("Lavc" in isft) or ("ffmpeg" in isft.lower())

    # ---- 判定 ----
    if sr == 44100 and ch == 2 and bits == 16 and is_pcm:
        verdict = "✅ A: ffmpeg -ar 44100 -ac 2 -s16"
        command = ("ffmpeg -y -i in.wav -ar 44100 -ac 2 "
                   "-sample_fmt s16 -f wav out.wav")
        if not has_lavf:
            verdict += "(无 Lavf → TTS 原始即 44100/2ch)"
    elif sr == 22050 and ch == 1 and bits == 16 and is_pcm:
        if has_lavf:
            verdict = "✅ B: ffmpeg -ar 22050 -ac 1 -s16"
            command = ("ffmpeg -y -i in.wav -ar 22050 -ac 1 "
                       "-sample_fmt s16 out.wav")
        else:
            verdict = "⚠ B(未转) / 原文件本身 22050/1ch"
            command = "(need=False 跳过转换) 或 TTS 原始格式"
    else:
        verdict = f"❓ 其他: {sr}Hz/{ch}ch/{bits}bit"
        command = ""

    return {
        "path": path, "name": os.path.basename(path),
        "sr": sr, "ch": ch, "bits": bits,
        "codec": "PCM" if is_pcm else f"tag={fmt['audio_format']}",
        "dur": f"{dur:.3f}",
        "size_kb": size_kb,
        "isft": isft or "(无)",
        "lavf": "✅" if has_lavf else "❌",
        "verdict": verdict,
        "command": command
    }


# ==================== UI ====================

# (key, 表头, 宽度, 对齐)
COLUMNS = [
    ("idx",     "#",       45,  "center"),
    ("name",    "文件名",   230, "w"),
    ("sr",      "采样率",   75,  "center"),
    ("ch",      "声道",     55,  "center"),
    ("bits",    "位深",     55,  "center"),
    ("codec",   "格式",     70,  "center"),
    ("dur",     "时长(s)",  75,  "e"),
    ("size_kb", "大小(KB)", 75,  "e"),
    ("isft",    "编码器(ISFT)", 150, "w"),
    ("lavf",    "ffmpeg",   65,  "center"),
    ("verdict", "判定",     300, "w"),
]


class App:
    def __init__(self, root):
        self.root = root
        self.root.title("WAV 逆向分析工具 --- 批量判定 ffmpeg 来源")
        self.root.geometry("1280x760")
        self.root.minsize(1000, 600)

        self.results = []        # List[dict]
        self.scan_dir = ""

        self._build_ui()

    # ---------- UI ----------
    def _build_ui(self):
        try:
            ttk.Style().theme_use("clam")
        except Exception:
            pass

        # 顶部工具栏
        top = ttk.Frame(self.root, padding=(10, 8))
        top.pack(fill=tk.X)

        ttk.Label(top, text="文件夹:").pack(side=tk.LEFT)
        self.var_dir = tk.StringVar()
        ttk.Entry(top, textvariable=self.var_dir, width=60).pack(
            side=tk.LEFT, padx=(0, 6), fill=tk.X, expand=True)

        ttk.Button(top, text="📁 选择", width=10,
                   command=self.pick_dir).pack(side=tk.LEFT, padx=2)
        self.var_recur = tk.BooleanVar(value=True)
        ttk.Checkbutton(top, text="递归子目录",
                        variable=self.var_recur).pack(side=tk.LEFT, padx=6)
        ttk.Button(top, text="▶ 开始分析", width=12,
                   command=self.run_scan).pack(side=tk.LEFT, padx=4)

        ttk.Separator(top, orient=tk.VERTICAL).pack(
            side=tk.LEFT, fill=tk.Y, padx=8)

        ttk.Button(top, text="💾 导出 CSV", width=12,
                   command=self.export_csv).pack(side=tk.LEFT, padx=2)
        ttk.Button(top, text="📊 导出 Excel", width=12,
                   command=self.export_xlsx).pack(side=tk.LEFT, padx=2)
        ttk.Button(top, text="🧹 清空", width=8,
                   command=self.clear).pack(side=tk.LEFT, padx=2)

        # 中间表格
        mid = ttk.Frame(self.root, padding=(10, 0))
        mid.pack(fill=tk.BOTH, expand=True)

        self.tree = ttk.Treeview(mid, columns=[c[0] for c in COLUMNS],
                                 show="headings", selectmode="extended")
        for key, title, width, anchor in COLUMNS:
            self.tree.heading(key, text=title,
                              command=lambda k=key: self.sort_by(k))
            self.tree.column(key, width=width, anchor=anchor, stretch=False)

        vsb = ttk.Scrollbar(mid, orient="vertical", command=self.tree.yview)
        hsb = ttk.Scrollbar(mid, orient="horizontal", command=self.tree.xview)
        self.tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set)

        self.tree.grid(row=0, column=0, sticky="nsew")
        vsb.grid(row=0, column=1, sticky="ns")
        hsb.grid(row=1, column=0, sticky="ew")
        mid.rowconfigure(0, weight=1)
        mid.columnconfigure(0, weight=1)

        self.tree.bind("<Double-1>", self.on_double_click)

        # 底部状态 + 进度
        bottom = ttk.Frame(self.root, padding=(10, 6))
        bottom.pack(fill=tk.X)

        self.var_status = tk.StringVar(value="就绪:请选择文件夹")
        ttk.Label(bottom, textvariable=self.var_status,
                  foreground="#0066cc").pack(side=tk.LEFT)

        self.prog = ttk.Progressbar(bottom, mode="determinate", length=260)
        self.prog.pack(side=tk.RIGHT)

    # ---------- 交互 ----------
    def pick_dir(self):
        d = filedialog.askdirectory(title="选择包含 WAV 文件的文件夹")
        if d:
            self.var_dir.set(d)

    def clear(self):
        self.results.clear()
        for iid in self.tree.get_children():
            self.tree.delete(iid)
        self.prog["value"] = 0
        self.var_status.set("已清空")

    def on_double_click(self, _event):
        sel = self.tree.selection()
        if not sel:
            return
        idx = int(sel[0])
        if 0 <= idx < len(self.results):
            path = self.results[idx]["path"]
            try:
                if hasattr(os, "startfile"):
                    os.startfile(path)     # Windows
                elif sys_is_mac():
                    subprocess.Popen(["open", path])
                else:
                    subprocess.Popen(["xdg-open", path])
            except Exception as e:
                messagebox.showerror("打开失败", str(e))

    # ---------- 扫描 ----------
    def run_scan(self):
        d = self.var_dir.get().strip()
        if not d or not os.path.isdir(d):
            messagebox.showwarning("提示", "请选择有效的文件夹")
            return

        wav_files = []
        if self.var_recur.get():
            for root, _, files in os.walk(d):
                for fn in files:
                    if fn.lower().endswith(".wav"):
                        wav_files.append(os.path.join(root, fn))
        else:
            for fn in os.listdir(d):
                p = os.path.join(d, fn)
                if os.path.isfile(p) and fn.lower().endswith(".wav"):
                    wav_files.append(p)

        if not wav_files:
            messagebox.showinfo("提示", "该文件夹下没有 .wav 文件")
            return

        self.clear()
        self.scan_dir = d
        total = len(wav_files)
        self.prog["maximum"] = total
        self.prog["value"] = 0

        for i, path in enumerate(wav_files, 1):
            self.var_status.set(f"正在分析 {i}/{total}:{os.path.basename(path)}")
            r = analyze_wav(path)
            self.results.append(r)
            self._insert_row(len(self.results) - 1, r)
            self.prog["value"] = i
            if i % 30 == 0:
                self.root.update_idletasks()

        self.root.update_idletasks()
        self.var_status.set(f"完成:共分析 {total} 个 WAV 文件")

    def _insert_row(self, idx, r):
        self.tree.insert("", "end", iid=str(idx), values=(
            idx + 1, r["name"], r["sr"], r["ch"], r["bits"], r["codec"],
            r["dur"], r["size_kb"], r["isft"], r["lavf"], r["verdict"]
        ))

    # ---------- 排序 ----------
    def sort_by(self, key):
        if not self.results:
            return

        def sort_key(r):
            v = r.get(key, "")
            # 数值列按 float 排,其他按字符串
            if key in ("sr", "ch", "bits", "size_kb"):
                try: return (0, float(v))
                except: return (1, str(v))
            if key == "dur":
                try: return (0, float(v))
                except: return (1, str(v))
            return (2, str(v).lower())

        self.results.sort(key=sort_key)
        for iid in self.tree.get_children():
            self.tree.delete(iid)
        for i, r in enumerate(self.results):
            self._insert_row(i, r)

    # ---------- 导出 ----------
    def _ensure_results(self):
        if not self.results:
            messagebox.showwarning("提示", "没有可导出的数据,请先分析")
            return False
        return True

    def _default_name(self, ext):
        base = os.path.basename(self.scan_dir.rstrip("/\\")) or "wav_report"
        return f"{base}_分析_{datetime.now():%Y%m%d_%H%M%S}.{ext}"

    def export_csv(self):
        if not self._ensure_results():
            return
        path = filedialog.asksaveasfilename(
            title="导出 CSV",
            defaultextension=".csv",
            initialfile=self._default_name("csv"),
            filetypes=[("CSV 文件", "*.csv"), ("所有文件", "*.*")]
        )
        if not path:
            return
        try:
            with open(path, "w", encoding="utf-8-sig", newline="") as f:
                w = csv.writer(f)
                w.writerow(["序号", "文件名", "采样率", "声道", "位深",
                            "格式", "时长(s)", "大小(KB)",
                            "编码器(ISFT)", "ffmpeg处理", "判定",
                            "建议命令", "完整路径"])
                for i, r in enumerate(self.results, 1):
                    w.writerow([i, r["name"], r["sr"], r["ch"], r["bits"],
                                r["codec"], r["dur"], r["size_kb"],
                                r["isft"], r["lavf"], r["verdict"],
                                r["command"], r["path"]])
            self.var_status.set(f"已导出:{path}")
            messagebox.showinfo("成功", f"已导出 {len(self.results)} 条记录到:\n{path}")
        except Exception as e:
            messagebox.showerror("导出失败", str(e))

    def export_xlsx(self):
        if not self._ensure_results():
            return
        if not HAS_OPENPYXL:
            messagebox.showerror(
                "缺少依赖",
                "导出 Excel 需要 openpyxl,请先执行:\n\npip install openpyxl")
            return

        path = filedialog.asksaveasfilename(
            title="导出 Excel",
            defaultextension=".xlsx",
            initialfile=self._default_name("xlsx"),
            filetypes=[("Excel 文件", "*.xlsx"), ("所有文件", "*.*")]
        )
        if not path:
            return
        try:
            wb = Workbook()
            ws = wb.active
            ws.title = "WAV 分析"
            headers = ["序号", "文件名", "采样率", "声道", "位深",
                       "格式", "时长(s)", "大小(KB)",
                       "编码器(ISFT)", "ffmpeg处理", "判定",
                       "建议命令", "完整路径"]
            ws.append(headers)
            for i, r in enumerate(self.results, 1):
                ws.append([i, r["name"], r["sr"], r["ch"], r["bits"],
                           r["codec"], r["dur"], r["size_kb"],
                           r["isft"], r["lavf"], r["verdict"],
                           r["command"], r["path"]])

            # 简单列宽
            widths = [6, 28, 10, 8, 8, 10, 10, 10, 22, 10, 40, 60, 60]
            for col, w in enumerate(widths, 1):
                ws.column_dimensions[chr(64 + col) if col <= 26
                                     else 'A' + chr(64 + col - 26)].width = w
            ws.freeze_panes = "A2"

            wb.save(path)
            self.var_status.set(f"已导出:{path}")
            messagebox.showinfo("成功", f"已导出 {len(self.results)} 条记录到:\n{path}")
        except Exception as e:
            messagebox.showerror("导出失败", str(e))


def sys_is_mac():
    import sys
    return sys.platform == "darwin"


if __name__ == "__main__":
    root = tk.Tk()
    App(root)
    root.mainloop()

五、跑出来的结论

样本一:现有 6 个文件(fault / feedback / fire / mask / start / supervise)

指标 值
采样率 / 声道 / 位深 22050 / 1 / 16
ISFT (空)
判定 ⚠ B(未转)

结论 :pyttsx3(SAPI5)原始输出就是 22050/1ch,恰好跟 need 判断条件一致 → need = False → if 分支一次都没进过,ffmpeg 命令从没执行。

那段脚本里写的 44100 命令,在当前环境下被"巧合"掩盖了。换台机器 / 换个 TTS 引擎就会爆。

样本二:ttsaudio.wav

指标 值
采样率 / 声道 / 位深 16000 / 1 / 16
ISFT (空)
判定 ❓ 其他

结论 :16000 Hz 是窄带语音的常见采样率(.NET SpeechSynthesizer 或部分中文 SAPI 语音包),不是本脚本产物,是另一套工具链生成的,命名和格式都没统一。


六、顺手得到的几个经验

  1. need 判断和 cmd 参数必须对齐,写死成常量最保险:
复制代码
TARGET_SR, TARGET_CH, TARGET_BITS = 22050, 1, 16
need = (sr != TARGET_SR or ch != TARGET_CH or bits != TARGET_BITS)
cmd  = [..., "-ar", str(TARGET_SR), "-ac", str(TARGET_CH), ...]
  1. 提示音用 22050/16bit/单声道就够了,44100/立体声体积是 4 倍,听感对语音没区别。

  2. need=False 的跳过分支要打日志,否则转换失效半年都不会有人发现。

  3. 多来源音频入版前跑一遍这个工具,格式混用问题一眼就能看出来。


七、使用方法

复制代码
# 1. 装依赖(只有导出 Excel 才需要)
pip install openpyxl
​
# 2. 运行
python wav_forensics_gui.py
​
# 3. 选文件夹 → 点"开始分析" → 看表格 / 导出

代码约 400 行,纯标准库(tkinter + struct + csv),无其他依赖。


八、备忘

  • 输入 :含 .wav 的任意目录,可递归

  • 输出:CSV(UTF-8 BOM)/ XLSX

  • 双击:调起系统播放器

  • 判定依据 :fmt 块 + ISFT 块的 Lavf 标记

  • 典型用法:音频资源入库前格式审查、TTS 来源溯源


------ 主要留档用,下次遇到"这音频哪来的 / 格式怎么不对"这类问题,直接跑一遍就清楚了。

相关推荐
滴啦嘟啦哒1 小时前
【具身笔记】【π0.5 微调与仿真】五、在MuJoCo中实现可视化仿真
笔记·python·vla·具身
xier_ran1 小时前
【infra之路】AI 编译器学习笔记(十三):AI 编译器全景——TVM、XLA、IREE 与其他
人工智能·笔记·学习
连山齐名1 小时前
ai应用开发的学习笔记
服务器·笔记·学习
乔旭伟2 小时前
中小厂生产管理系统选型笔记:驱动方式、主线链路与成本结构
笔记·需求分析·规格说明书
xier_ran2 小时前
【infra之路】AI 编译器学习笔记(十四):为什么要 tiling,以及矩阵计算单元的“固定“
人工智能·笔记·学习
如旧呀2 小时前
origin绘制等距的HSD置信区间
笔记·origin
问心无愧05133 小时前
[极客大挑战 2019]Upload
笔记
蒸蒸yyyyzwd3 小时前
CMU 11-768 p1-3 笔记
笔记
Generalzy4 小时前
Qt 桌面开发入门:PySide6 学习路线、架构原理与技术选型
笔记·python·学习