1a57878f76
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 1m12s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 2m17s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 1m33s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 3m14s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
1. 未使用依赖清理:
- 从 requirements-base.txt 移除 cryptography 和 pyOpenSSL
2. pyflakes 警告清零 (apps/ + packages/ + tests/):
- 移除 17 处未使用的 import (F401)
- 修复 26 处未使用的局部变量 (F841):
* 有副作用的赋值转为裸调用
* 无副作用的赋值直接删除
- 修复 1 处未使用的异常变量 (F841)
- 修复 1 处空 except 块
3. 测试文件冗余清理:
- 删除 tests/integration/test_project_management.py (模块级 skip,测试不存在的模块)
- 删除 tests/integration/fixtures/duplication_routes_fixed.py (未被引用)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
323 lines
9.3 KiB
Python
323 lines
9.3 KiB
Python
"""音频对比工具 — 基于 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)
|