feat: songrate 初始版本 - 音乐多维度评估系统

- 7 大维度分析器: 节奏/可舞性/能量/情绪/调性/音色/音频质量
- 6 种场景配置: pop/classical/electronic/rock/jazz/hiphop
- 4 个 API: scenes/dimensions/config/evaluate
- 基于 librosa 的纯算法分析(CPU 即可运行)
- nginx IP 白名单认证(无 RBAC)
This commit is contained in:
yumoqing 2026-06-03 18:01:08 +08:00
parent ab52aaab4d
commit 44a2ac9bb7
20 changed files with 941 additions and 1 deletions

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
__pycache__/
*.pyc
.pytest_cache/
*.egg-info/
dist/
build/

View File

@ -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 <SAGE_SERVER_IP>;
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": {...}},
...
]
}
```

1
models/README.md Normal file
View File

@ -0,0 +1 @@
# models

3
requirements.txt Normal file
View File

@ -0,0 +1,3 @@
librosa>=0.10.0
numpy>=1.24.0
soundfile>=0.12.0

37
scripts/load_path.py Normal file
View File

@ -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)")

3
songrate/__init__.py Normal file
View File

@ -0,0 +1,3 @@
# songrate - 音乐多维度评估系统
from .evaluator import evaluate_song
from .scenes import get_scenes, get_scene_config, set_scene_config

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

104
songrate/evaluator.py Normal file
View File

@ -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
}

180
songrate/scenes.py Normal file
View File

@ -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

41
wwwroot/api/config.dspy Normal file
View File

@ -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)

View File

@ -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)

32
wwwroot/api/evaluate.dspy Normal file
View File

@ -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)

6
wwwroot/api/scenes.dspy Normal file
View File

@ -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)