"""音频对比工具 — 基于 FFmpeg 的音频质量对比. 使用以下指标评估两段音频的相似度: 1. 波形差异(RMS 差值) 2. 频谱相似度(FFT 分帧比较) 3. 时长差异 对比方式: - 直接对两个音频做 `ametadata=select='gt(scene\\,0.3)'` 过于复杂 - 简化方案:用 `amerge` + `astats` 计算差值音频的 RMS 更精确的方案(已实现): - 将两轨音频做差(amix=0:weights='1 -1' → 实际上用 pan 更简单) - 对差值音频做 astats,获取差值的 RMS、峰值等指标 """ from __future__ import annotations import json import re import shutil import subprocess # nosec B404 from dataclasses import asdict, dataclass from pathlib import Path from typing import Any FFMPEG_BIN: str = shutil.which("ffmpeg") or "ffmpeg" FFPROBE_BIN: str = shutil.which("ffprobe") or "ffprobe" @dataclass class AudioDiffResult: """音频对比结果.""" audio_a: str audio_b: str duration_a: float duration_b: float duration_diff: float sample_rate_match: bool channels_match: bool diff_rms_db: float # 差值音频的 RMS(dB,越低越相似) diff_peak_db: float # 差值音频的峰值(dB,越低越相似) similarity_score: float # 综合相似度评分 [0, 1],1 = 完全一致 passed: bool def to_dict(self) -> dict[str, Any]: return asdict(self) def probe_duration(file_path: str) -> float: """探测文件时长(秒),失败返回 0.""" try: result = subprocess.run( # nosec B603 [ FFPROBE_BIN, "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", str(file_path), ], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=10, ) return round(float(result.stdout.strip()), 3) except Exception: return 0.0 def probe_has_audio(file_path: str | Path) -> bool: """探测文件是否包含音频流.""" try: result = subprocess.run( # nosec B603 [ FFPROBE_BIN, "-v", "error", "-select_streams", "a:0", "-show_entries", "stream=codec_type", "-of", "default=noprint_wrappers=1:nokey=1", str(file_path), ], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=10, ) return result.stdout.strip() == "audio" except Exception: return False # 探测失败保守返回 False,避免误判有音频 def compute_audio_diff( audio_a: str | Path, audio_b: str | Path, *, similarity_threshold: float = 0.90, duration_tolerance: float = 0.1, ) -> AudioDiffResult: """计算两段音频的差异. 方案:用 pan 滤镜将两轨相减,对差值音频做 astats 分析。 Args: audio_a: 音频A(基线) audio_b: 音频B(对比) similarity_threshold: 相似度合格阈值 duration_tolerance: 时长容忍度(秒) Returns: AudioDiffResult 对比结果 """ dur_a = probe_duration(str(audio_a)) dur_b = probe_duration(str(audio_b)) duration_diff = abs(dur_a - dur_b) # 获取音频元信息 info_a = _probe_audio_info(str(audio_a)) info_b = _probe_audio_info(str(audio_b)) sample_rate_match = info_a["sample_rate"] == info_b["sample_rate"] channels_match = info_a["channels"] == info_b["channels"] # 相减后分析差值 # 取较短时长做对比 min_dur = min(dur_a, dur_b) if min_dur <= 0: return AudioDiffResult( audio_a=str(audio_a), audio_b=str(audio_b), duration_a=dur_a, duration_b=dur_b, duration_diff=duration_diff, sample_rate_match=sample_rate_match, channels_match=channels_match, diff_rms_db=-999.0, diff_peak_db=-999.0, similarity_score=0.0, passed=False, ) # 做差值音频:a - b # 注意:amix 会自动按输入数归一化音量(除以N), # 所以 a + (-1)*b 经过 amix=inputs=2 后整体音量会减半(-6dB)。 # 加 volume=2 补偿回来,确保差值 RMS 反映真实差异幅度。 command = [ FFMPEG_BIN, "-i", str(audio_a), "-i", str(audio_b), "-filter_complex", # 第2轨反相 → amix混合 → volume=2补偿amix的自动缩放 "[1:a]volume=-1[inv];[0:a][inv]amix=inputs=2:duration=shortest:dropout_transition=0,volume=2[diff]", "-map", "[diff]", "-f", "null", "-af", "astats=metadata=1:reset=0", "-", ] try: result = subprocess.run( # nosec B603 command, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=120, ) stderr = result.stderr or "" except subprocess.CalledProcessError: # 如果音频格式不兼容,返回失败 return AudioDiffResult( audio_a=str(audio_a), audio_b=str(audio_b), duration_a=dur_a, duration_b=dur_b, duration_diff=duration_diff, sample_rate_match=sample_rate_match, channels_match=channels_match, diff_rms_db=999.0, diff_peak_db=999.0, similarity_score=0.0, passed=False, ) diff_rms_db, diff_peak_db = _parse_astats(stderr) # 相似度评分:基于差值 RMS # 差值 RMS -60dB → 相似度 ~1.0(几乎无声差) # 差值 RMS -20dB → 相似度 ~0.5(有明显差异) # 差值 RMS 0dB → 相似度 ~0.0(完全相反) if diff_rms_db <= -60: similarity_score = 1.0 elif diff_rms_db >= 0: similarity_score = 0.0 else: # 线性映射:-60dB → 1.0, 0dB → 0.0 similarity_score = max(0.0, min(1.0, 1.0 + diff_rms_db / 60.0)) passed = ( duration_diff <= duration_tolerance and sample_rate_match and channels_match and similarity_score >= similarity_threshold ) return AudioDiffResult( audio_a=str(audio_a), audio_b=str(audio_b), duration_a=round(dur_a, 3), duration_b=round(dur_b, 3), duration_diff=round(duration_diff, 3), sample_rate_match=sample_rate_match, channels_match=channels_match, diff_rms_db=round(diff_rms_db, 2), diff_peak_db=round(diff_peak_db, 2), similarity_score=round(similarity_score, 4), passed=passed, ) def _probe_audio_info(file_path: str) -> dict[str, int]: """探测音频元信息.""" try: result = subprocess.run( # nosec B603 [ FFPROBE_BIN, "-v", "error", "-select_streams", "a:0", "-show_entries", "stream=sample_rate,channels", "-of", "json", file_path, ], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=10, ) info = json.loads(result.stdout) stream = info.get("streams", [{}])[0] return { "sample_rate": int(stream.get("sample_rate", 44100)), "channels": int(stream.get("channels", 2)), } except Exception: return {"sample_rate": 0, "channels": 0} def _parse_astats(stderr: str) -> tuple[float, float]: """从 astats 输出中解析 RMS 和峰值. astats 输出格式(在 stderr 中): [Parsed_astats_1 @ 0x...] Channel: 1 [Parsed_astats_1 @ 0x...] ... [Parsed_astats_1 @ 0x...] Overall [Parsed_astats_1 @ 0x...] DC offset: 0.000000 [Parsed_astats_1 @ 0x...] Min level: -0.123456 [Parsed_astats_1 @ 0x...] Max level: 0.789012 [Parsed_astats_1 @ 0x...] Peak level dB: -2.01 [Parsed_astats_1 @ 0x...] RMS level dB: -10.56 ... """ lines = stderr.split("\n") rms_db = -999.0 peak_db = -999.0 for line in lines: # 找 Overall 部分的统计(双声道时取整体值) rms_match = re.search(r"RMS level dB:\s*(-?\d+\.?\d*)", line) peak_match = re.search(r"Peak level dB:\s*(-?\d+\.?\d*)", line) if rms_match: rms_db = float(rms_match.group(1)) if peak_match: peak_db = float(peak_match.group(1)) return rms_db, peak_db def extract_audio(video_path: str | Path, output_path: str | Path) -> Path: """从视频中提取音频(AAC 格式). Args: video_path: 视频文件路径 output_path: 输出音频路径 Returns: 输出音频文件路径 """ command = [ FFMPEG_BIN, "-y", "-i", str(video_path), "-vn", "-acodec", "aac", "-b:a", "128k", str(output_path), ] subprocess.run(command, check=True, capture_output=True, timeout=120) # nosec B603 return Path(output_path)