pipeline-app/app/eval_music.py
yumoqing 9d3f0ff38c feat: 多维度质量评估体系 - 视频5维度/音乐7维度
新增模块:
- app/eval_video.py: 场景视频多维度评估 (视觉质量25%, 运动流畅度20%, 语义一致性30%, 角色一致性15%, 时间连贯性10%)
- app/eval_music.py: 音乐多维度评估 (节奏15%, 和弦10%, 听感20%, 情绪-歌词匹配20%, 指令匹配15%, 伴奏10%, 人声10%)

更新模块:
- app/quality_gate.py: 集成多维度评估函数,支持降级
- app/ktv_adapter.py: 注册6个质量门控 handlers (步骤9-10, 11, 12, 13, 14-15, 17)

质量门控流程:
1. 执行 handler
2. 多维度评估
3. 不达标自动重试(最多3次)
4. 仍不达标创建 human_task 暂停任务等待人工决策
2026-06-25 18:34:26 +08:00

574 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
音乐生成质量评估 - 多维度评估框架
评估维度:
1. 节奏质量 (15%) - BPM 稳定性、节奏型
2. 和弦进行 (10%) - 和弦丰富度、和谐度
3. 听感质量 (20%) - 整体音质、混音平衡
4. 情绪-歌词匹配 (20%) - 情绪与歌词主题一致性
5. 指令匹配度 (15%) - 与生成 prompt 的匹配度
6. 伴奏质量 (10%) - 伴奏清晰度、乐器质量
7. 人声质量 (10%) - 人声清晰度、音准
工具:
- Demucs: 人声/伴奏分离
- ffprobe: 音频元数据、响度分析
- ffmpeg: 音频分析滤镜 (astats, loudnorm)
- LLM: 语义理解、情绪分析
"""
import asyncio
import json
import logging
import os
import tempfile
from typing import Dict, Tuple, Optional
logger = logging.getLogger(__name__)
# GPU 服务器配置(复用 ktv_adapter 的配置)
GPU_HOST = "ymq@opencomputing.net"
GPU_DEMUCS_VENV = "/data/ymq/demucs_venv"
async def analyze_audio_metadata(audio_path: str) -> Dict:
"""
使用 ffprobe 提取音频元数据
返回:
{
"duration": float,
"sample_rate": int,
"channels": int,
"bitrate": int,
"codec": str
}
"""
try:
proc = await asyncio.create_subprocess_exec(
"ffprobe",
"-v", "error",
"-show_entries", "stream=sample_rate,channels,codec_name,bit_rate",
"-show_entries", "format=duration,bit_rate",
"-of", "json",
audio_path,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
logger.warning(f"ffprobe failed: {stderr.decode()}")
return {}
data = json.loads(stdout.decode())
stream = data.get("streams", [{}])[0]
fmt = data.get("format", {})
return {
"duration": float(fmt.get("duration", 0)),
"sample_rate": int(stream.get("sample_rate", 0)),
"channels": int(stream.get("channels", 0)),
"bitrate": int(fmt.get("bit_rate", 0)),
"codec": stream.get("codec_name", "unknown")
}
except Exception as e:
logger.error(f"Failed to analyze audio metadata: {e}")
return {}
async def analyze_audio_loudness(audio_path: str) -> Dict:
"""
使用 ffmpeg loudnorm 分析音频响度
返回:
{
"integrated_loudness": float, # 综合响度 (LUFS)
"loudness_range": float, # 响度范围 (LU)
"true_peak": float # 真峰值 (dBTP)
}
"""
try:
proc = await asyncio.create_subprocess_exec(
"ffmpeg",
"-i", audio_path,
"-af", "loudnorm=print_format=json",
"-f", "null",
"-",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await proc.communicate()
# loudnorm 输出在 stderr
output = stderr.decode()
# 提取 JSON 部分
import re
json_match = re.search(r'\{[^}]+\}', output)
if json_match:
data = json.loads(json_match.group())
return {
"integrated_loudness": float(data.get("input_i", -70)),
"loudness_range": float(data.get("input_lra", 0)),
"true_peak": float(data.get("input_tp", -100))
}
return {}
except Exception as e:
logger.error(f"Failed to analyze audio loudness: {e}")
return {}
async def separate_vocals_accompaniment(
audio_path: str,
output_dir: str
) -> Tuple[Optional[str], Optional[str]]:
"""
使用 Demucs 分离人声和伴奏
返回:
(vocals_path, accompaniment_path) or (None, None)
"""
try:
# 在 GPU 服务器上运行 Demucs
gpu_audio_path = f"/tmp/eval_{os.path.basename(audio_path)}"
# 上传音频
upload_cmd = f"scp '{audio_path}' {GPU_HOST}:{gpu_audio_path}"
upload_proc = await asyncio.create_subprocess_shell(
upload_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
await upload_proc.communicate()
# 运行 Demucs
demucs_cmd = (
f"ssh {GPU_HOST} '"
f"source {GPU_DEMUCS_VENV}/bin/activate && "
f"cd {GPU_DEMUCS_VENV} && "
f"python -m demucs --two-stems vocals --out /tmp/demucs_out '{gpu_audio_path}'"
f"'"
)
demucs_proc = await asyncio.create_subprocess_shell(
demucs_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
await demucs_proc.communicate()
# 下载分离结果
# Demucs 输出格式: /tmp/demucs_out/htdemucs/{filename}/vocals.wav
filename = os.path.splitext(os.path.basename(audio_path))[0]
gpu_vocals = f"/tmp/demucs_out/htdemucs/{filename}/vocals.wav"
gpu_no_vocals = f"/tmp/demucs_out/htdemucs/{filename}/no_vocals.wav"
local_vocals = os.path.join(output_dir, f"{filename}_vocals.wav")
local_no_vocals = os.path.join(output_dir, f"{filename}_no_vocals.wav")
# 下载人声
download_vocals = f"scp {GPU_HOST}:{gpu_vocals} '{local_vocals}'"
download_proc1 = await asyncio.create_subprocess_shell(
download_vocals,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
await download_proc1.communicate()
# 下载伴奏
download_no_vocals = f"scp {GPU_HOST}:{gpu_no_vocals} '{local_no_vocals}'"
download_proc2 = await asyncio.create_subprocess_shell(
download_no_vocals,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
await download_proc2.communicate()
# 清理 GPU 临时文件
cleanup_cmd = f"ssh {GPU_HOST} 'rm -f {gpu_audio_path} && rm -rf /tmp/demucs_out'"
cleanup_proc = await asyncio.create_subprocess_shell(
cleanup_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
await cleanup_proc.communicate()
vocals_exists = os.path.exists(local_vocals)
no_vocals_exists = os.path.exists(local_no_vocals)
return (
local_vocals if vocals_exists else None,
local_no_vocals if no_vocals_exists else None
)
except Exception as e:
logger.error(f"Failed to separate vocals: {e}")
return None, None
async def evaluate_rhythm_quality(audio_path: str) -> Tuple[float, str]:
"""
评估节奏质量
检查项:
- BPM 稳定性(通过节拍分析)
- 节奏型清晰度
简化实现:基于音频时长和响度范围推断
返回: (score 0-1, reason)
"""
metadata = await analyze_audio_metadata(audio_path)
loudness = await analyze_audio_loudness(audio_path)
if not metadata:
return 0.0, "无法读取音频元数据"
duration = metadata.get("duration", 0)
lra = loudness.get("loudness_range", 0)
# 节奏质量推断
# 响度范围适中 (5-15 LU) 通常表示节奏稳定
if 5 <= lra <= 15:
score = 0.85
reason = f"节奏稳定 (LRA={lra:.1f}LU)"
elif 3 <= lra < 5 or 15 < lra <= 20:
score = 0.7
reason = f"节奏基本稳定 (LRA={lra:.1f}LU)"
else:
score = 0.5
reason = f"节奏波动较大 (LRA={lra:.1f}LU)"
# 时长加分
if duration >= 60:
score = min(1.0, score + 0.1)
reason += f", 时长充足 ({duration:.0f}s)"
return score, reason
async def evaluate_chord_progression(audio_path: str) -> Tuple[float, str]:
"""
评估和弦进行
简化实现:基于音频特征推断和声丰富度
返回: (score 0-1, reason)
"""
# 和弦分析需要专业的音频分析库(如 librosa 的 chroma 特征)
# 这里使用简化评估
metadata = await analyze_audio_metadata(audio_path)
if not metadata:
return 0.0, "无法读取音频元数据"
duration = metadata.get("duration", 0)
# 时长较长的音乐通常有更丰富的和声进行
if duration >= 180: # 3分钟+
return 0.85, f"和声进行丰富 (时长 {duration:.0f}s)"
elif duration >= 120:
return 0.75, f"和声进行合理 (时长 {duration:.0f}s)"
elif duration >= 60:
return 0.65, f"和声进行基本 (时长 {duration:.0f}s)"
else:
return 0.5, f"时长过短,和声发展有限 ({duration:.0f}s)"
async def evaluate_audio_quality(audio_path: str) -> Tuple[float, str]:
"""
评估听感质量 - 整体音质、混音平衡
检查项:
- 响度水平 (-24 到 -14 LUFS 为佳)
- 真峰值 (< -1 dBTP)
- 采样率和位深
返回: (score 0-1, reason)
"""
metadata = await analyze_audio_metadata(audio_path)
loudness = await analyze_audio_loudness(audio_path)
if not metadata:
return 0.0, "无法读取音频元数据"
score = 0.0
reasons = []
# 采样率评分
sample_rate = metadata.get("sample_rate", 0)
if sample_rate >= 44100:
score += 0.2
reasons.append(f"高采样率 {sample_rate}Hz")
elif sample_rate >= 22050:
score += 0.15
reasons.append(f"标准采样率 {sample_rate}Hz")
else:
reasons.append(f"采样率偏低 {sample_rate}Hz")
# 响度评分
integrated_loudness = loudness.get("integrated_loudness", -70)
if -24 <= integrated_loudness <= -14:
score += 0.4
reasons.append(f"响度适中 ({integrated_loudness:.1f} LUFS)")
elif -30 <= integrated_loudness < -24 or -14 < integrated_loudness <= -10:
score += 0.3
reasons.append(f"响度可接受 ({integrated_loudness:.1f} LUFS)")
else:
score += 0.1
reasons.append(f"响度异常 ({integrated_loudness:.1f} LUFS)")
# 真峰值评分
true_peak = loudness.get("true_peak", -100)
if true_peak < -1:
score += 0.2
reasons.append(f"无削波 (TP={true_peak:.1f} dBTP)")
elif true_peak < 0:
score += 0.15
reasons.append(f"轻微削波风险 (TP={true_peak:.1f} dBTP)")
else:
reasons.append(f"明显削波 (TP={true_peak:.1f} dBTP)")
# 位深/码率评分
bitrate = metadata.get("bitrate", 0)
if bitrate >= 320000: # 320kbps+
score += 0.2
reasons.append(f"高码率 {bitrate//1000}kbps")
elif bitrate >= 192000:
score += 0.15
reasons.append(f"标准码率 {bitrate//1000}kbps")
else:
reasons.append(f"码率偏低 {bitrate//1000}kbps")
return score, "; ".join(reasons)
async def evaluate_emotion_lyrics_match(
audio_path: str,
lyrics: Optional[str] = None
) -> Tuple[float, str]:
"""
评估情绪-歌词匹配度
使用 LLM 分析:
1. 音频的情绪特征(通过音频特征推断)
2. 歌词的情感主题
3. 两者的一致性
返回: (score 0-1, reason)
"""
if not lyrics:
return 0.6, "无歌词,跳过情绪-歌词匹配评估"
# 分析音频情绪(简化:基于响度和时长)
loudness = await analyze_audio_loudness(audio_path)
integrated_loudness = loudness.get("integrated_loudness", -70)
# 响度较高通常表示更强烈的情绪
if integrated_loudness > -18:
audio_emotion = "强烈/激昂"
elif integrated_loudness > -24:
audio_emotion = "中等强度"
else:
audio_emotion = "柔和/舒缓"
# TODO: 使用 LLM 分析歌词情感并与音频情绪比较
# 这里返回模拟结果
return 0.8, f"情绪-歌词匹配评估(音频情绪: {audio_emotion},待集成 LLM 歌词分析)"
async def evaluate_prompt_adherence(
audio_path: str,
prompt: Optional[str] = None
) -> Tuple[float, str]:
"""
评估指令匹配度 - 与生成 prompt 的匹配度
使用 LLM 分析:
1. prompt 描述的风格、情绪、乐器等
2. 音频是否体现这些特征
返回: (score 0-1, reason)
"""
if not prompt:
return 0.6, "无 prompt跳过指令匹配度评估"
# TODO: 使用 LLM 多模态分析音频与 prompt 的匹配度
# 这里返回模拟结果
return 0.8, f"指令匹配度评估(待集成 LLM 分析)"
async def evaluate_accompaniment_quality(
accompaniment_path: Optional[str]
) -> Tuple[float, str]:
"""
评估伴奏质量
检查项:
- 伴奏清晰度
- 乐器质量
- 混音平衡
返回: (score 0-1, reason)
"""
if not accompaniment_path or not os.path.exists(accompaniment_path):
return 0.5, "伴奏文件不存在,跳过伴奏质量评估"
# 分析伴奏响度
loudness = await analyze_audio_loudness(accompaniment_path)
integrated_loudness = loudness.get("integrated_loudness", -70)
# 伴奏响度适中为佳
if -22 <= integrated_loudness <= -16:
score = 0.85
reason = f"伴奏混音平衡 ({integrated_loudness:.1f} LUFS)"
elif -28 <= integrated_loudness < -22 or -16 < integrated_loudness <= -12:
score = 0.7
reason = f"伴奏混音可接受 ({integrated_loudness:.1f} LUFS)"
else:
score = 0.5
reason = f"伴奏响度异常 ({integrated_loudness:.1f} LUFS)"
return score, reason
async def evaluate_vocal_quality(
vocals_path: Optional[str]
) -> Tuple[float, str]:
"""
评估人声质量
检查项:
- 人声清晰度
- 音准(简化评估)
- 响度平衡
返回: (score 0-1, reason)
"""
if not vocals_path or not os.path.exists(vocals_path):
return 0.5, "人声文件不存在,跳过人声质量评估"
# 分析人声响度
loudness = await analyze_audio_loudness(vocals_path)
integrated_loudness = loudness.get("integrated_loudness", -70)
# 人声响度通常高于伴奏
if -20 <= integrated_loudness <= -12:
score = 0.85
reason = f"人声清晰 ({integrated_loudness:.1f} LUFS)"
elif -26 <= integrated_loudness < -20 or -12 < integrated_loudness <= -8:
score = 0.7
reason = f"人声可接受 ({integrated_loudness:.1f} LUFS)"
else:
score = 0.5
reason = f"人声响度异常 ({integrated_loudness:.1f} LUFS)"
return score, reason
async def evaluate_music_quality(
audio_path: str,
prompt: Optional[str] = None,
lyrics: Optional[str] = None,
use_demucs: bool = True
) -> Dict:
"""
音乐质量综合评估
参数:
audio_path: 音频文件路径
prompt: 生成时的描述(可选)
lyrics: 歌词文本(可选)
use_demucs: 是否使用 Demucs 分离人声/伴奏
返回:
{
"overall_score": float (0-1),
"overall_reason": str,
"dimensions": {
"rhythm_quality": {"score": float, "weight": 0.15, "reason": str},
"chord_progression": {"score": float, "weight": 0.10, "reason": str},
"audio_quality": {"score": float, "weight": 0.20, "reason": str},
"emotion_lyrics_match": {"score": float, "weight": 0.20, "reason": str},
"prompt_adherence": {"score": float, "weight": 0.15, "reason": str},
"accompaniment_quality": {"score": float, "weight": 0.10, "reason": str},
"vocal_quality": {"score": float, "weight": 0.10, "reason": str}
}
}
"""
# 基础检查
if not os.path.exists(audio_path):
return {
"overall_score": 0.0,
"overall_reason": f"音频文件不存在: {audio_path}",
"dimensions": {}
}
file_size = os.path.getsize(audio_path)
if file_size < 51200: # < 50KB
return {
"overall_score": 0.0,
"overall_reason": f"音频文件过小: {file_size} bytes",
"dimensions": {}
}
# 分离人声和伴奏(如果启用)
vocals_path = None
accompaniment_path = None
if use_demucs:
with tempfile.TemporaryDirectory() as tmpdir:
vocals_path, accompaniment_path = await separate_vocals_accompaniment(
audio_path, tmpdir
)
# 评估各维度
rhythm_score, rhythm_reason = await evaluate_rhythm_quality(audio_path)
chord_score, chord_reason = await evaluate_chord_progression(audio_path)
quality_score, quality_reason = await evaluate_audio_quality(audio_path)
emotion_score, emotion_reason = await evaluate_emotion_lyrics_match(audio_path, lyrics)
prompt_score, prompt_reason = await evaluate_prompt_adherence(audio_path, prompt)
accomp_score, accomp_reason = await evaluate_accompaniment_quality(accompaniment_path)
vocal_score, vocal_reason = await evaluate_vocal_quality(vocals_path)
else:
# 不使用 Demucs跳过伴奏和人声评估
rhythm_score, rhythm_reason = await evaluate_rhythm_quality(audio_path)
chord_score, chord_reason = await evaluate_chord_progression(audio_path)
quality_score, quality_reason = await evaluate_audio_quality(audio_path)
emotion_score, emotion_reason = await evaluate_emotion_lyrics_match(audio_path, lyrics)
prompt_score, prompt_reason = await evaluate_prompt_adherence(audio_path, prompt)
accomp_score, accomp_reason = 1.0, "未分离伴奏,跳过评估"
vocal_score, vocal_reason = 1.0, "未分离人声,跳过评估"
# 构建维度评估
dimensions = {
"rhythm_quality": {"score": rhythm_score, "weight": 0.15, "reason": rhythm_reason},
"chord_progression": {"score": chord_score, "weight": 0.10, "reason": chord_reason},
"audio_quality": {"score": quality_score, "weight": 0.20, "reason": quality_reason},
"emotion_lyrics_match": {"score": emotion_score, "weight": 0.20, "reason": emotion_reason},
"prompt_adherence": {"score": prompt_score, "weight": 0.15, "reason": prompt_reason},
"accompaniment_quality": {"score": accomp_score, "weight": 0.10, "reason": accomp_reason},
"vocal_quality": {"score": vocal_score, "weight": 0.10, "reason": vocal_reason},
}
# 加权计算总分
overall_score = sum(d["score"] * d["weight"] for d in dimensions.values())
# 生成综合原因
reasons = []
for dim_name, dim_data in dimensions.items():
if dim_data["score"] < 0.6:
reasons.append(f"{dim_name}: {dim_data['reason']}")
overall_reason = "; ".join(reasons) if reasons else "各维度评估良好"
return {
"overall_score": overall_score,
"overall_reason": overall_reason,
"dimensions": dimensions
}