pipeline-app/app/eval_music.py
yumoqing eb10d97aa2 feat: eval_video/eval_music 接入 LLM 评估
- eval_video.py: semantic_consistency + character_consistency 调用 llm_bridge
- eval_music.py: emotion_lyrics_match + prompt_adherence 调用 llm_bridge
- 异常时回退默认分数,保证产线不中断
2026-06-25 21:10:35 +08:00

649 lines
22 KiB
Python
Raw Permalink 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 re
import tempfile
from typing import Dict, Tuple, Optional
from pipeline_service.llm_bridge import llm_call
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 = "柔和/舒缓"
try:
llm_prompt = f"""分析歌词情感与音频特征的匹配度。
歌词:
{lyrics}
音频情绪特征: {audio_emotion} (响度: {integrated_loudness:.1f} LUFS)
请评估歌词表达的情感是否与音频的情绪特征一致:
1. 歌词的主题和情感倾向
2. 与音频情绪的匹配程度
请严格按照以下 JSON 格式返回,不要输出其他内容:
{{"match_score": <0到10的整数>, "reason": "简要说明匹配情况"}}
"""
response = await llm_call(llm_prompt, temperature=0.3)
# 提取 JSON兼容 markdown 代码块包裹)
json_match = re.search(r'\{[^{}]*\}', response)
if not json_match:
logger.warning(f"LLM 返回无法解析为 JSON: {response[:200]}")
return 0.6, f"LLM 返回格式异常,降级评估(音频情绪: {audio_emotion}"
data = json.loads(json_match.group())
match_score = float(data.get("match_score", 8))
reason = data.get("reason", "LLM 评估完成")
# 转换为 0-1 分数
score = max(0.0, min(1.0, match_score / 10.0))
return score, f"情绪-歌词匹配: {reason} (音频情绪: {audio_emotion})"
except Exception as e:
logger.warning(f"情绪-歌词匹配 LLM 评估失败: {e}")
return 0.6, f"LLM 分析异常,降级评估(音频情绪: {audio_emotion}: {e}"
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跳过指令匹配度评估"
try:
# 获取音频特征
metadata = await analyze_audio_metadata(audio_path)
loudness = await analyze_audio_loudness(audio_path)
duration = metadata.get("duration", 0)
integrated_loudness = loudness.get("integrated_loudness", -70)
loudness_range = loudness.get("loudness_range", 0)
true_peak = loudness.get("true_peak", -100)
llm_prompt = f"""评估一段 AI 生成的音频与生成指令之间的匹配度。
【生成指令 (prompt)】
{prompt}
【音频特征(从音频信号中提取)】
- 时长: {duration:.1f}
- 综合响度: {integrated_loudness:.1f} LUFS
- 响度范围: {loudness_range:.1f} LU
- 真峰值: {true_peak:.1f} dBTP
请根据以上信息,评估指令中描述的风格、情绪、乐器、速度等特征是否在音频的物理特性中得到体现。
注意:你无法直接听到音频,只能通过上述特征进行推断。请结合常识分析(例如:高响度+窄响度范围通常对应激烈/电子风格,低响度+宽响度范围通常对应舒缓/原声风格)。
请严格按照以下 JSON 格式返回,不要输出其他内容:
{{"match_score": <0到10的整数>, "reason": "简要说明匹配情况"}}
"""
response = await llm_call(llm_prompt, temperature=0.3)
# 提取 JSON兼容 markdown 代码块包裹)
json_match = re.search(r'\{[^{}]*\}', response)
if not json_match:
logger.warning(f"LLM 返回无法解析为 JSON: {response[:200]}")
return 0.6, "LLM 返回格式异常,降级评估"
data = json.loads(json_match.group())
match_score = float(data.get("match_score", 8))
reason = data.get("reason", "LLM 评估完成")
# 转换为 0-1 分数
score = max(0.0, min(1.0, match_score / 10.0))
return score, f"指令匹配: {reason}"
except Exception as e:
logger.warning(f"指令匹配度 LLM 评估失败: {e}")
return 0.6, f"LLM 分析异常,降级评估: {e}"
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
}