记录一下这个工具是怎么来的、怎么用、以及它帮我发现了什么问题。主要给自己看的备忘。
一、为什么要写这个工具
项目里有一段音频转换脚本,看起来没问题,但仔细一看逻辑是矛盾的:
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 语音包),不是本脚本产物,是另一套工具链生成的,命名和格式都没统一。
六、顺手得到的几个经验
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), ...]
-
提示音用 22050/16bit/单声道就够了,44100/立体声体积是 4 倍,听感对语音没区别。
-
need=False的跳过分支要打日志,否则转换失效半年都不会有人发现。 -
多来源音频入版前跑一遍这个工具,格式混用问题一眼就能看出来。
七、使用方法
# 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 来源溯源
------ 主要留档用,下次遇到"这音频哪来的 / 格式怎么不对"这类问题,直接跑一遍就清楚了。