diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ec69fe3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.pyc +.pytest_cache/ +*.egg-info/ +dist/ +build/ diff --git a/README.md b/README.md index b50b35a..45a96a6 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,65 @@ -# songrate +# songrate — 音乐多维度评估系统 +基于场景化权重的音乐质量评估 HTTP 服务。 + +## 特性 + +- **场景化评分**: 不同音乐类型使用不同权重(流行/古典/电子/摇滚/爵士/嘻哈) +- **7 大维度**: 节奏、可舞性、能量、情绪、调性、音色、音频质量 +- **加权总分**: 各维度小分 × 权重 = 最终评分 + +## 部署 + +```bash +# 1. 安装依赖 +pip install -r requirements.txt + +# 2. 注册路径 +python scripts/load_path.py + +# 3. 启动服务 (端口 8900) +ahserver -p 8900 +``` + +## nginx 配置 + +```nginx +location /songrate/api/ { + allow ; + allow 127.0.0.1; + deny all; + proxy_pass http://127.0.0.1:8900; + proxy_read_timeout 300s; +} +``` + +## API + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | /songrate/api/scenes.dspy | 获取可用场景 | +| GET | /songrate/api/dimensions.dspy?scene=pop | 获取维度树及权重 | +| POST | /songrate/api/config.dspy | 设置场景权重 | +| POST | /songrate/api/evaluate.dspy | 评估歌曲 | + +### 评估示例 + +```bash +curl -X POST http://localhost:8900/songrate/api/evaluate.dspy \ + -F "filepath=/path/to/song.mp3" \ + -F "scene=pop" +``` + +返回: +```json +{ + "total_score": 7.85, + "scene": "pop", + "scene_name": "流行音乐", + "dimensions": [ + {"key": "rhythm", "name": "节奏", "score": 8.2, "weight": 0.25, "weighted": 2.05, "details": {...}}, + {"key": "danceability", "name": "可舞性", "score": 7.5, "weight": 0.20, "weighted": 1.50, "details": {...}}, + ... + ] +} +``` diff --git a/models/README.md b/models/README.md new file mode 100644 index 0000000..0ae9e22 --- /dev/null +++ b/models/README.md @@ -0,0 +1 @@ +# models diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..182fd70 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +librosa>=0.10.0 +numpy>=1.24.0 +soundfile>=0.12.0 diff --git a/scripts/load_path.py b/scripts/load_path.py new file mode 100644 index 0000000..32dcc67 --- /dev/null +++ b/scripts/load_path.py @@ -0,0 +1,37 @@ +"""load_path.py — 注册 API 路径到 RBAC (any 权限,由 nginx 做 IP 白名单)""" +import sys +import os + +MOD = "songrate" + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..')) +from sqlor.dbpools import DBPools +from sqlor import sor + +dbpools = DBPools() +dbname = dbpools.get_module_dbname(MOD) +if not dbname: + dbname = 'sage' + +sor.init(dbpools.get_pool(dbname)) + +PATHS_ANY = [ + f"/{MOD}", + f"/{MOD}/api/scenes.dspy", + f"/{MOD}/api/dimensions.dspy", + f"/{MOD}/api/evaluate.dspy", + f"/{MOD}/api/config.dspy", +] + +for path in PATHS_ANY: + recs = sor.R("rolepermission", {"role": "any", "path": path}) + if not recs: + sor.C("rolepermission", { + "role": "any", + "path": path, + }) + print(f" + {path}") + else: + print(f" = {path}") + +print(f"\nsongrate paths registered ({len(PATHS_ANY)} paths)") diff --git a/songrate/__init__.py b/songrate/__init__.py new file mode 100644 index 0000000..d8bb029 --- /dev/null +++ b/songrate/__init__.py @@ -0,0 +1,3 @@ +# songrate - 音乐多维度评估系统 +from .evaluator import evaluate_song +from .scenes import get_scenes, get_scene_config, set_scene_config diff --git a/songrate/analyzers/__init__.py b/songrate/analyzers/__init__.py new file mode 100644 index 0000000..0e973df --- /dev/null +++ b/songrate/analyzers/__init__.py @@ -0,0 +1,18 @@ +"""音频分析器基类和工具函数""" +import numpy as np + + +def load_audio(filepath, sr=22050): + """加载音频文件,返回 (y, sr)""" + import librosa + return librosa.load(filepath, sr=sr) + + +def safe_float(val, default=0.0): + """安全转换为 float,处理 NaN/Inf""" + if val is None: + return default + val = float(val) + if np.isnan(val) or np.isinf(val): + return default + return val diff --git a/songrate/analyzers/danceability.py b/songrate/analyzers/danceability.py new file mode 100644 index 0000000..e751b29 --- /dev/null +++ b/songrate/analyzers/danceability.py @@ -0,0 +1,51 @@ +"""可舞性分析 - 低频占比、节拍清晰度、节奏规律性""" +import numpy as np +from . import safe_float + + +def analyze_danceability(y, sr): + """ + 分析可舞性维度 + """ + import librosa + + # 低频能量占比 (bass ratio) + S = np.abs(librosa.stft(y)) + freqs = librosa.fft_frequencies(sr=sr) + + low_mask = freqs < 250 # 低频 < 250Hz + low_energy = np.sum(S[low_mask] ** 2) + total_energy = np.sum(S ** 2) + bass_ratio = safe_float(low_energy / total_energy) if total_energy > 0 else 0.0 + + # 节拍清晰度 + onset_env = librosa.onset.onset_strength(y=y, sr=sr) + pulse = librosa.beat.plp(onset_envelope=onset_env, sr=sr) + beat_clarity = safe_float(min(np.mean(pulse), 1.0)) + + # 节奏规律性 - 自相关峰值 + if len(onset_env) > 10: + autocorr = np.correlate(onset_env, onset_env, mode='full') + autocorr = autocorr[len(autocorr) // 2:] + if autocorr[0] > 0: + autocorr = autocorr / autocorr[0] + # 找第一个显著峰值(排除前几个采样点) + peaks = np.where(autocorr[10:] > 0.5)[0] + regularity = safe_float(min(len(peaks) / 20.0, 1.0)) if len(peaks) > 0 else 0.3 + else: + regularity = 0.5 + + scores = { + "bass_ratio": round(bass_ratio * 10, 2), + "beat_clarity": round(beat_clarity * 10, 2), + "regularity": round(regularity * 10, 2), + } + + score = ( + 0.35 * scores["bass_ratio"] + + 0.35 * scores["beat_clarity"] + + 0.30 * scores["regularity"] + ) + scores["score"] = round(score, 2) + + return scores diff --git a/songrate/analyzers/energy.py b/songrate/analyzers/energy.py new file mode 100644 index 0000000..56f23b7 --- /dev/null +++ b/songrate/analyzers/energy.py @@ -0,0 +1,52 @@ +"""能量分析 - RMS、动态范围、能量变化""" +import numpy as np +from . import safe_float + + +def analyze_energy(y, sr): + """ + 分析能量维度 + """ + import librosa + + # RMS 能量 + rms = librosa.feature.rms(y=y, frame_length=2048, hop_length=512)[0] + rms_db = librosa.amplitude_to_db(rms, ref=np.max) + rms_mean = safe_float(np.mean(rms_db)) + + # 动态范围 (dB) + rms_valid = rms_db[rms_db > -60] # 过滤静音段 + if len(rms_valid) > 0: + dynamic_range = safe_float(np.max(rms_valid) - np.min(rms_valid)) + else: + dynamic_range = 0.0 + + # 能量变化 - 能量曲线的变异系数 + if len(rms) > 0 and np.mean(rms) > 0: + variation = safe_float(np.std(rms) / np.mean(rms)) + variation_score = min(variation / 0.5, 1.0) # 50% 变化以上满分 + else: + variation_score = 0.0 + + scores = { + "rms": round(rms_mean, 2), + "dynamic_range": round(dynamic_range, 2), + "variation": round(variation_score * 10, 2), + } + + # 评分逻辑:动态范围合理 + 有起伏 = 高分 + # 动态范围:15-30 dB 为优秀(不过度压缩也不过于极端) + if dynamic_range >= 10 and dynamic_range <= 35: + dr_score = 8.0 + min((dynamic_range - 10) / 25 * 2, 2.0) + elif dynamic_range > 35: + dr_score = 7.0 # 过大,可能有静音段 + else: + dr_score = dynamic_range / 10 * 8.0 # 过小,过度压缩 + + score = ( + 0.40 * dr_score + + 0.60 * scores["variation"] + ) + scores["score"] = round(min(score, 10), 2) + + return scores diff --git a/songrate/analyzers/mood.py b/songrate/analyzers/mood.py new file mode 100644 index 0000000..e59c010 --- /dev/null +++ b/songrate/analyzers/mood.py @@ -0,0 +1,72 @@ +"""情绪分析 - 愉悦度、唤醒度、情绪清晰度""" +import numpy as np +from . import safe_float + + +def analyze_mood(y, sr): + """ + 分析情绪维度(基于音频特征的启发式方法) + 后续可升级为 CLAP 深度学习模型 + """ + import librosa + + # 提取特征 + # 1. 频谱质心 (brightness) → 关联 valence + spectral_centroid = librosa.feature.spectral_centroid(y=y, sr=sr)[0] + brightness = safe_float(np.mean(spectral_centroid)) + + # 2. 频谱对比度 → 关联 arousal + spectral_contrast = librosa.feature.spectral_contrast(y=y, sr=sr) + contrast_mean = safe_float(np.mean(spectral_contrast)) + + # 3. 过零率 → 关联 arousal + zcr = librosa.feature.zero_crossing_rate(y)[0] + zcr_mean = safe_float(np.mean(zcr)) + + # 4. RMS → 关联 arousal + rms = librosa.feature.rms(y=y)[0] + rms_mean = safe_float(np.mean(rms)) + + # 5. 调性 (major/minor) → 关联 valence + chroma = librosa.feature.chroma_stft(y=y, sr=sr) + major_profile = np.array([6.35, 2.23, 3.48, 2.33, 4.38, 4.09, + 2.52, 5.19, 2.39, 3.66, 2.29, 2.88]) + minor_profile = np.array([6.33, 2.68, 3.52, 5.38, 2.60, 3.53, + 2.54, 4.75, 3.98, 2.69, 3.34, 3.17]) + major_profile = major_profile / np.sum(major_profile) + minor_profile = minor_profile / np.sum(minor_profile) + + chroma_mean = np.mean(chroma, axis=1) + major_corr = np.dot(chroma_mean, major_profile) / (np.linalg.norm(chroma_mean) * np.linalg.norm(major_profile)) + minor_corr = np.dot(chroma_mean, minor_profile) / (np.linalg.norm(chroma_mean) * np.linalg.norm(minor_profile)) + is_major = major_corr >= minor_corr + + # 计算 valence (愉悦度): 大调 + 高亮度 + 高对比度 → 快乐 + # 归一化到 0-1 + brightness_norm = min(brightness / 4000, 1.0) # 4000Hz 以上算高亮 + valence = 0.4 * brightness_norm + 0.3 * (1.0 if is_major else 0.3) + 0.3 * min(contrast_mean / 30, 1.0) + valence = safe_float(min(max(valence, 0), 1.0)) + + # 计算 arousal (唤醒度): 高 ZCR + 高 RMS → 兴奋 + zcr_norm = min(zcr_mean / 0.1, 1.0) + rms_norm = min(rms_mean / 0.1, 1.0) + arousal = 0.5 * zcr_norm + 0.5 * rms_norm + arousal = safe_float(min(max(arousal, 0), 1.0)) + + # 情绪清晰度 - 特征是否集中在某个象限 + # 如果 valence 和 arousal 都接近 0.5,说明情绪模糊 + valence_distance = abs(valence - 0.5) * 2 # 0-1,离中心越远越清晰 + arousal_distance = abs(arousal - 0.5) * 2 + clarity = (valence_distance + arousal_distance) / 2 + + scores = { + "valence": round(valence * 10, 2), + "arousal": round(arousal * 10, 2), + "clarity": round(clarity * 10, 2), + } + + # 情绪评分:清晰度为主 + score = 0.40 * scores["clarity"] + 0.30 * scores["valence"] + 0.30 * scores["arousal"] + scores["score"] = round(min(score, 10), 2) + + return scores diff --git a/songrate/analyzers/quality.py b/songrate/analyzers/quality.py new file mode 100644 index 0000000..287fbce --- /dev/null +++ b/songrate/analyzers/quality.py @@ -0,0 +1,63 @@ +"""音频质量分析 - 信噪比、削波检测、频率均衡""" +import numpy as np +from . import safe_float + + +def analyze_quality(y, sr): + """ + 分析音频质量维度 + """ + import librosa + + # 信噪比估算 - 信号能量 vs 安静段能量 + rms = librosa.feature.rms(y=y, frame_length=2048, hop_length=512)[0] + rms_sorted = np.sort(rms) + n = len(rms_sorted) + if n > 10: + noise_floor = np.mean(rms_sorted[:n // 10]) # 最安静的 10% + signal_level = np.mean(rms_sorted[-n // 10:]) # 最响的 10% + if noise_floor > 0: + snr_db = safe_float(20 * np.log10(signal_level / noise_floor)) + else: + snr_db = 60.0 # 无噪声 + else: + snr_db = 30.0 + + # 削波检测 - 检查是否有接近 1.0 或 -1.0 的采样 + clipped = np.sum(np.abs(y) > 0.99) / len(y) + clipping_score = 10.0 if clipped < 0.001 else max(0, 10.0 - clipped * 10000) + + # 频率均衡 - 频谱平坦度 + spectral_flatness = librosa.feature.spectral_flatness(y=y)[0] + flatness_mean = safe_float(np.mean(spectral_flatness)) + # 频谱太平坦 = 白噪声,太不平坦 = 某些频段缺失 + # 理想范围:0.001 - 0.1 + if 0.001 <= flatness_mean <= 0.1: + freq_balance = 8.0 + elif flatness_mean < 0.001: + freq_balance = 6.0 # 过于集中 + else: + freq_balance = 5.0 # 过于平坦 + + scores = { + "snr": round(snr_db, 2), + "clipping": round(clipping_score, 2), + "frequency_balance": round(freq_balance, 2), + } + + # SNR 评分: >40dB 优秀, 20-40 良好, <20 差 + if snr_db >= 40: + snr_score = 10.0 + elif snr_db >= 20: + snr_score = 6.0 + (snr_db - 20) / 20 * 4.0 + else: + snr_score = max(snr_db / 20 * 6.0, 0) + + score = ( + 0.40 * snr_score + + 0.35 * scores["clipping"] + + 0.25 * scores["frequency_balance"] + ) + scores["score"] = round(min(score, 10), 2) + + return scores diff --git a/songrate/analyzers/rhythm.py b/songrate/analyzers/rhythm.py new file mode 100644 index 0000000..1934db0 --- /dev/null +++ b/songrate/analyzers/rhythm.py @@ -0,0 +1,65 @@ +"""节奏分析 - BPM、节拍清晰度、稳定性、律动感""" +import numpy as np +from . import safe_float + + +def analyze_rhythm(y, sr): + """ + 分析节奏维度 + 输入: y (音频数据), sr (采样率) + 输出: dict {bpm, beat_clarity, stability, groove, score} + """ + import librosa + + # BPM + tempo, beats = librosa.beat.beat_track(y=y, sr=sr) + bpm = safe_float(tempo) + + # 节拍清晰度 (beat_track 返回的 confidence) + # 新版 librosa 返回 tuple (tempo, beats),confidence 需要从 onset strength 推算 + onset_env = librosa.onset.onset_strength(y=y, sr=sr) + pulse = librosa.beat.plp(onset_envelope=onset_env, sr=sr) + beat_clarity = safe_float(np.mean(pulse)) + # 归一化到 0-1 + beat_clarity = min(beat_clarity, 1.0) + + # 节奏稳定性 - 节拍间隔的变异系数 + if len(beats) > 2: + beat_times = librosa.frames_to_time(beats, sr=sr) + intervals = np.diff(beat_times) + if np.mean(intervals) > 0: + cv = np.std(intervals) / np.mean(intervals) + stability = safe_float(1.0 - min(cv, 1.0)) + else: + stability = 0.0 + else: + stability = 0.5 + + # 律动感 - 基于 onset strength 的方差(高方差 = 更多动态变化 = 更有律动) + if len(onset_env) > 0: + onset_std = np.std(onset_env) + onset_mean = np.mean(onset_env) + if onset_mean > 0: + groove = safe_float(min(onset_std / onset_mean, 1.0)) + else: + groove = 0.0 + else: + groove = 0.0 + + # 子维度评分(均为 0-10) + scores = { + "bpm": bpm, + "beat_clarity": round(beat_clarity * 10, 2), + "stability": round(stability * 10, 2), + "groove": round(groove * 10, 2), + } + + # 维度综合分(不限制 BPM,只评估质量) + score = ( + 0.35 * scores["beat_clarity"] + + 0.30 * scores["stability"] + + 0.35 * scores["groove"] + ) + scores["score"] = round(score, 2) + + return scores diff --git a/songrate/analyzers/timbre.py b/songrate/analyzers/timbre.py new file mode 100644 index 0000000..e0252a3 --- /dev/null +++ b/songrate/analyzers/timbre.py @@ -0,0 +1,54 @@ +"""音色分析 - 音色丰富度、频谱平衡""" +import numpy as np +from . import safe_float + + +def analyze_timbre(y, sr): + """ + 分析音色维度 + """ + import librosa + + # 频谱质心 (brightness) + spectral_centroid = librosa.feature.spectral_centroid(y=y, sr=sr)[0] + centroid_mean = safe_float(np.mean(spectral_centroid)) + + # MFCC (13维音色指纹) + mfcc = librosa.feature.mfcc(y=y, sr=sr, n_mfcc=13) + mfcc_mean = np.mean(mfcc, axis=1) + + # 音色丰富度 - MFCC 的方差(方差大 = 音色变化丰富) + mfcc_variance = np.var(mfcc, axis=1) + richness = safe_float(np.mean(mfcc_variance)) + richness_norm = min(richness / 100, 1.0) # 归一化 + + # 频谱平衡 - 各频段能量分布的均匀度 + S = np.abs(librosa.stft(y)) + freqs = librosa.fft_frequencies(sr=sr) + + # 分频段:低频 (<250Hz), 中频 (250-2000Hz), 高频 (>2000Hz) + low = np.sum(S[freqs < 250] ** 2) + mid = np.sum((freqs >= 250) & (freqs < 2000)) + mid_energy = np.sum(S[(freqs >= 250) & (freqs < 2000)] ** 2) + high_energy = np.sum(S[freqs >= 2000] ** 2) + + total = low + mid_energy + high_energy + if total > 0: + ratios = [low / total, mid_energy / total, high_energy / total] + # 理想比例:低频 0.2-0.4,中频 0.3-0.5,高频 0.1-0.3 + # 计算与理想比例的偏差 + ideal = [0.3, 0.4, 0.2] + deviation = sum(abs(r - i) for r, i in zip(ratios, ideal)) + balance = safe_float(1.0 - min(deviation / 1.0, 1.0)) + else: + balance = 0.0 + + scores = { + "richness": round(richness_norm * 10, 2), + "balance": round(balance * 10, 2), + } + + score = 0.50 * scores["richness"] + 0.50 * scores["balance"] + scores["score"] = round(min(score, 10), 2) + + return scores diff --git a/songrate/analyzers/tonality.py b/songrate/analyzers/tonality.py new file mode 100644 index 0000000..de9bfbc --- /dev/null +++ b/songrate/analyzers/tonality.py @@ -0,0 +1,57 @@ +"""调性分析 - 调性清晰度、和声丰富度、转调合理性""" +import numpy as np +from . import safe_float + + +def analyze_tonality(y, sr): + """ + 分析调性维度 + """ + import librosa + + # Chroma 特征 + chroma = librosa.feature.chroma_stft(y=y, sr=sr) + + # 调性清晰度 - chroma 的峰值与均值之比 + chroma_mean = np.mean(chroma, axis=1) + if np.sum(chroma_mean) > 0: + peak_ratio = np.max(chroma_mean) / np.mean(chroma_mean) + key_clarity = safe_float(min((peak_ratio - 1.0) / 2.0, 1.0)) # 峰值是均值的 2 倍以上满分 + else: + key_clarity = 0.0 + + # 和声丰富度 - 活跃音级数量 + active_notes = np.sum(chroma_mean > np.mean(chroma_mean) * 0.5) + harmony = safe_float(min(active_notes / 7.0, 1.0)) # 7 个以上活跃音级满分 + + # 转调检测 - 滑动窗口 chroma 变化 + hop = chroma.shape[1] // 4 + if hop > 10 and chroma.shape[1] > hop * 2: + seg1 = np.mean(chroma[:, :hop], axis=1) + seg4 = np.mean(chroma[:, -hop:], axis=1) + # 余弦相似度 + cos_sim = np.dot(seg1, seg4) / (np.linalg.norm(seg1) * np.linalg.norm(seg4) + 1e-10) + # 相似度高 = 没转调(稳定),中等 = 有转调(合理),低 = 大转调 + if cos_sim > 0.8: + modulation_score = 0.8 # 稳定 + elif cos_sim > 0.5: + modulation_score = 1.0 # 适度转调 + else: + modulation_score = 0.5 # 大转调 + else: + modulation_score = 0.7 # 无法判断,给中等分 + + scores = { + "key_clarity": round(key_clarity * 10, 2), + "harmony": round(harmony * 10, 2), + "modulation": round(modulation_score * 10, 2), + } + + score = ( + 0.40 * scores["key_clarity"] + + 0.35 * scores["harmony"] + + 0.25 * scores["modulation"] + ) + scores["score"] = round(min(score, 10), 2) + + return scores diff --git a/songrate/evaluator.py b/songrate/evaluator.py new file mode 100644 index 0000000..8ac4d56 --- /dev/null +++ b/songrate/evaluator.py @@ -0,0 +1,104 @@ +"""主评估逻辑 - 编排所有分析器,返回加权总分""" +import os +import numpy as np +from .scenes import get_scene_config, SCENES, DIMENSIONS +from .analyzers.rhythm import analyze_rhythm +from .analyzers.danceability import analyze_danceability +from .analyzers.energy import analyze_energy +from .analyzers.mood import analyze_mood +from .analyzers.tonality import analyze_tonality +from .analyzers.timbre import analyze_timbre +from .analyzers.quality import analyze_quality + + +ANALYZER_MAP = { + "rhythm": analyze_rhythm, + "danceability": analyze_danceability, + "energy": analyze_energy, + "mood": analyze_mood, + "tonality": analyze_tonality, + "timbre": analyze_timbre, + "quality": analyze_quality, +} + + +def evaluate_song(filepath, scene="pop"): + """ + 评估一首歌曲 + + 参数: + filepath: 音频文件路径 + scene: 音乐场景 (pop/classical/electronic/rock/jazz/hiphop) + + 返回: + dict: { + "total_score": float, + "scene": str, + "dimensions": [ + {"key": str, "name": str, "score": float, "weight": float, "weighted": float, "details": dict} + ] + } + """ + import librosa + + if scene not in SCENES: + return {"error": f"未知场景: {scene}", "available_scenes": list(SCENES.keys())} + + if not os.path.exists(filepath): + return {"error": f"文件不存在: {filepath}"} + + # 加载音频 + try: + y, sr = librosa.load(filepath, sr=22050) + except Exception as e: + return {"error": f"音频加载失败: {str(e)}"} + + config = get_scene_config(scene) + weights = config["weights"] + + # 运行各维度分析器(仅运行启用且权重 > 0 的维度) + dimensions = [] + total_weighted = 0.0 + + for dim_key, weight in weights.items(): + if weight <= 0: + continue + + analyzer = ANALYZER_MAP.get(dim_key) + if not analyzer: + continue + + try: + result = analyzer(y, sr) + dim_score = result.pop("score", 0) + dim_name = DIMENSIONS.get(dim_key, {}).get("name", dim_key) + weighted = dim_score * weight + total_weighted += weighted + + dimensions.append({ + "key": dim_key, + "name": dim_name, + "score": dim_score, + "weight": weight, + "weighted": round(weighted, 2), + "details": result + }) + except Exception as e: + dimensions.append({ + "key": dim_key, + "name": DIMENSIONS.get(dim_key, {}).get("name", dim_key), + "score": 0, + "weight": weight, + "weighted": 0, + "details": {"error": str(e)} + }) + + # 按权重排序 + dimensions.sort(key=lambda d: d["weight"], reverse=True) + + return { + "total_score": round(total_weighted, 2), + "scene": scene, + "scene_name": config["name"], + "dimensions": dimensions + } diff --git a/songrate/scenes.py b/songrate/scenes.py new file mode 100644 index 0000000..95089ad --- /dev/null +++ b/songrate/scenes.py @@ -0,0 +1,180 @@ +"""场景配置 - 不同音乐类型的权重配置""" + +SCENES = { + "pop": { + "name": "流行音乐", + "description": "主流流行、流行摇滚、流行舞曲", + "weights": { + "rhythm": 0.25, + "danceability": 0.20, + "energy": 0.15, + "mood": 0.15, + "tonality": 0.10, + "timbre": 0.10, + "quality": 0.05 + } + }, + "classical": { + "name": "古典音乐", + "description": "古典、交响乐、室内乐", + "weights": { + "tonality": 0.25, + "timbre": 0.20, + "quality": 0.20, + "energy": 0.15, + "rhythm": 0.10, + "mood": 0.10, + "danceability": 0.00 + } + }, + "electronic": { + "name": "电子音乐", + "description": "EDM、House、Techno、Trance", + "weights": { + "rhythm": 0.30, + "danceability": 0.25, + "energy": 0.20, + "timbre": 0.10, + "mood": 0.10, + "tonality": 0.05, + "quality": 0.00 + } + }, + "rock": { + "name": "摇滚音乐", + "description": "摇滚、金属、朋克", + "weights": { + "energy": 0.25, + "rhythm": 0.20, + "timbre": 0.15, + "mood": 0.15, + "tonality": 0.10, + "danceability": 0.10, + "quality": 0.05 + } + }, + "jazz": { + "name": "爵士音乐", + "description": "爵士、布鲁斯、融合爵士", + "weights": { + "tonality": 0.25, + "rhythm": 0.20, + "timbre": 0.20, + "mood": 0.15, + "energy": 0.10, + "danceability": 0.05, + "quality": 0.05 + } + }, + "hiphop": { + "name": "嘻哈音乐", + "description": "Hip-Hop、Rap、Trap", + "weights": { + "rhythm": 0.30, + "energy": 0.20, + "danceability": 0.20, + "mood": 0.15, + "timbre": 0.10, + "tonality": 0.05, + "quality": 0.00 + } + } +} + +# 维度树定义 +DIMENSIONS = { + "rhythm": { + "name": "节奏", + "sub_dimensions": [ + {"key": "bpm", "name": "BPM", "description": "每分钟节拍数"}, + {"key": "beat_clarity", "name": "节拍清晰度", "description": "节拍是否清晰可辨"}, + {"key": "stability", "name": "节奏稳定性", "description": "节拍是否稳定"}, + {"key": "groove", "name": "律动感", "description": "节奏的摇摆感和人性化"} + ] + }, + "danceability": { + "name": "可舞性", + "sub_dimensions": [ + {"key": "bass_ratio", "name": "低频占比", "description": "低频能量占总能量比例"}, + {"key": "beat_clarity", "name": "节拍清晰度", "description": "节拍是否清晰"}, + {"key": "regularity", "name": "节奏规律性", "description": "节奏是否规律"} + ] + }, + "energy": { + "name": "能量", + "sub_dimensions": [ + {"key": "rms", "name": "RMS能量", "description": "整体响度"}, + {"key": "dynamic_range", "name": "动态范围", "description": "最响与最静之差"}, + {"key": "variation", "name": "能量变化", "description": "能量曲线起伏程度"} + ] + }, + "mood": { + "name": "情绪", + "sub_dimensions": [ + {"key": "valence", "name": "愉悦度", "description": "快乐 vs 悲伤"}, + {"key": "arousal", "name": "唤醒度", "description": "兴奋 vs 平静"}, + {"key": "clarity", "name": "情绪清晰度", "description": "情绪标签是否明确"} + ] + }, + "tonality": { + "name": "调性", + "sub_dimensions": [ + {"key": "key_clarity", "name": "调性清晰度", "description": "调性是否明确"}, + {"key": "harmony", "name": "和声丰富度", "description": "和弦进行是否丰富"}, + {"key": "modulation", "name": "转调合理性", "description": "转调是否自然"} + ] + }, + "timbre": { + "name": "音色", + "sub_dimensions": [ + {"key": "richness", "name": "音色丰富度", "description": "谐波数量和泛音"}, + {"key": "balance", "name": "频谱平衡", "description": "各频段能量分布"} + ] + }, + "quality": { + "name": "音频质量", + "sub_dimensions": [ + {"key": "snr", "name": "信噪比", "description": "信号与噪声比"}, + {"key": "clipping", "name": "削波检测", "description": "是否有削波失真"}, + {"key": "frequency_balance", "name": "频率均衡", "description": "频率分布是否均衡"} + ] + } +} + + +def get_scenes(): + """获取所有可用场景""" + return [ + { + "key": key, + "name": config["name"], + "description": config["description"] + } + for key, config in SCENES.items() + ] + + +def get_scene_config(scene_key): + """获取场景配置""" + if scene_key not in SCENES: + return None + return { + "scene": scene_key, + "name": SCENES[scene_key]["name"], + "dimensions": DIMENSIONS, + "weights": SCENES[scene_key]["weights"] + } + + +def set_scene_config(scene_key, weights): + """设置场景权重""" + if scene_key not in SCENES: + return False + + # 验证权重总和为 1.0 + total = sum(weights.values()) + if abs(total - 1.0) > 0.01: + return False + + SCENES[scene_key]["weights"] = weights + return True diff --git a/wwwroot/api/config.dspy b/wwwroot/api/config.dspy new file mode 100644 index 0000000..d63eb11 --- /dev/null +++ b/wwwroot/api/config.dspy @@ -0,0 +1,41 @@ +"""GET /api/config.dspy — 获取/设置场景权重配置""" +import json +from songrate.scenes import get_scene_config, set_scene_config, SCENES + +scene = params_kw.get('scene', '') + +# POST: 设置权重 +if params_kw.get('weights'): + weights = params_kw['weights'] + if isinstance(weights, str): + weights = json.loads(weights) + + if not scene: + return json.dumps({"error": "缺少 scene 参数"}, ensure_ascii=False) + + if set_scene_config(scene, weights): + return json.dumps({"success": True, "scene": scene, "weights": weights}, ensure_ascii=False) + else: + return json.dumps({"error": "设置失败,请检查场景名和权重总和是否为 1.0"}, ensure_ascii=False) + +# GET: 获取配置 +if not scene: + # 返回所有场景的配置 + result = {} + for key in SCENES: + config = get_scene_config(key) + result[key] = { + "name": config["name"], + "weights": config["weights"] + } + return json.dumps(result, ensure_ascii=False) + +config = get_scene_config(scene) +if not config: + return json.dumps({"error": f"未知场景: {scene}"}, ensure_ascii=False) + +return json.dumps({ + "scene": scene, + "name": config["name"], + "weights": config["weights"] +}, ensure_ascii=False) diff --git a/wwwroot/api/dimensions.dspy b/wwwroot/api/dimensions.dspy new file mode 100644 index 0000000..4315d16 --- /dev/null +++ b/wwwroot/api/dimensions.dspy @@ -0,0 +1,32 @@ +"""GET /api/dimensions.dspy — 获取维度树及权重""" +import json +from songrate.scenes import get_scene_config, SCENES, DIMENSIONS + +scene = params_kw.get('scene', 'pop') +if scene not in SCENES: + return json.dumps({"error": f"未知场景: {scene}", "available_scenes": list(SCENES.keys())}, ensure_ascii=False) + +config = get_scene_config(scene) +weights = config["weights"] + +# 构建维度树 +tree = [] +for dim_key, dim_info in DIMENSIONS.items(): + weight = weights.get(dim_key, 0) + tree.append({ + "key": dim_key, + "name": dim_info["name"], + "weight": weight, + "enabled": weight > 0, + "sub_dimensions": dim_info["sub_dimensions"] + }) + +# 按权重排序 +tree.sort(key=lambda d: d["weight"], reverse=True) + +return json.dumps({ + "scene": scene, + "scene_name": config["name"], + "dimensions": tree, + "total_weight": round(sum(weights.values()), 2) +}, ensure_ascii=False) diff --git a/wwwroot/api/evaluate.dspy b/wwwroot/api/evaluate.dspy new file mode 100644 index 0000000..6abe1a4 --- /dev/null +++ b/wwwroot/api/evaluate.dspy @@ -0,0 +1,32 @@ +"""POST /api/evaluate.dspy — 歌曲评估""" +import json +import os +import tempfile +from songrate.evaluator import evaluate_song + +# 获取参数 +scene = params_kw.get('scene', 'pop') +filepath = params_kw.get('filepath', '') + +# 如果没有 filepath,检查是否有上传文件 +if not filepath: + # 支持 multipart/form-data 上传 + file_content = params_kw.get('file_content', '') + filename = params_kw.get('filename', '') + if file_content and filename: + ext = os.path.splitext(filename)[1] or '.mp3' + filepath = os.path.join(tempfile.gettempdir(), f'songrate_{os.getpid()}{ext}') + with open(filepath, 'wb') as f: + f.write(file_content if isinstance(file_content, bytes) else file_content.encode()) + cleanup = True + else: + return json.dumps({"error": "缺少 filepath 或 file_content+filename 参数"}, ensure_ascii=False) +else: + cleanup = False + +try: + result = evaluate_song(filepath, scene) + return json.dumps(result, ensure_ascii=False) +finally: + if cleanup and os.path.exists(filepath): + os.remove(filepath) diff --git a/wwwroot/api/scenes.dspy b/wwwroot/api/scenes.dspy new file mode 100644 index 0000000..53d74dc --- /dev/null +++ b/wwwroot/api/scenes.dspy @@ -0,0 +1,6 @@ +"""GET /api/scenes.dspy — 获取可用场景列表""" +import json +from songrate.scenes import get_scenes + +scenes = get_scenes() +return json.dumps({"scenes": scenes}, ensure_ascii=False)