""" 音乐生成质量评估 - 多维度评估框架 评估维度: 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 }