Popiai-skill仓库
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

251 lines
9.1 KiB

#!/usr/bin/env python3
"""
energy_analyze.py — 音频情绪张力分析,找出能量最高的片段
用于 beat-sync-editor skill 的音频预处理步骤:
识别音频中情绪张力最强的区域,并计算若干目标时长下的最佳裁剪窗口。
Usage:
python energy_analyze.py <audio_file> [options]
Options:
--targets 目标时长列表(秒),逗号分隔,默认 "15,30,60"
--top-n 返回前 N 个候选区间(默认 1)
--out 输出格式:json(默认)| text
Output (JSON):
{
"total_duration": 202.08,
"bpm": 143.55,
"energy_peak": 87.3, # 能量最高点时间(秒)
"sections": [ # 能量分段(高→低排序)
{"start": 72.0, "end": 136.0, "energy_score": 0.94, "label": "高能段"},
...
],
"trim_options": [ # 各目标时长的最佳裁剪方案
{
"target_duration": 15,
"start": 80.1,
"end": 95.1,
"energy_score": 0.97,
"description": "最强节拍爆发区(副歌核心)"
},
{
"target_duration": 30,
"start": 72.0,
"end": 102.0,
"energy_score": 0.95,
"description": "副歌完整段落"
},
...
]
}
"""
import sys
import json
import argparse
def analyze_energy(audio_path, target_durations=None, top_n=1):
try:
import librosa
import numpy as np
except ImportError:
print("ERROR: librosa not installed. Run: pip install librosa", file=sys.stderr)
sys.exit(1)
if target_durations is None:
target_durations = [15, 30, 60]
# 加载音频
y, sr = librosa.load(audio_path, mono=True)
total_duration = librosa.get_duration(y=y, sr=sr)
# ── 1. 多维度能量特征 ──────────────────────────────────────────────
hop_length = 512
# RMS 能量(响度)
rms = librosa.feature.rms(y=y, hop_length=hop_length)[0]
# 频谱质心(音色明亮度)—— 高能量段通常质心更高
centroid = librosa.feature.spectral_centroid(y=y, sr=sr, hop_length=hop_length)[0]
# Onset 强度(节拍冲击感)
onset_env = librosa.onset.onset_strength(y=y, sr=sr, hop_length=hop_length)
# 归一化各特征到 [0, 1]
def norm(x):
mn, mx = x.min(), x.max()
return (x - mn) / (mx - mn + 1e-8)
rms_n = norm(rms)
cent_n = norm(centroid)
onset_n = norm(onset_env)
# 综合情绪张力分数(加权求和)
# 响度权重最高,节拍冲击其次,音色明亮度辅助
energy_score = 0.5 * rms_n + 0.3 * onset_n + 0.2 * cent_n
# 帧时间轴
frame_times = librosa.frames_to_time(
np.arange(len(energy_score)), sr=sr, hop_length=hop_length
)
# ── 2. 找能量峰值点 ───────────────────────────────────────────────
peak_frame = int(np.argmax(energy_score))
energy_peak = float(frame_times[peak_frame])
# ── 3. 结构分段(用频谱聚类识别段落边界)─────────────────────────
try:
# 使用 MFCC 做结构分析
mfcc = librosa.feature.mfcc(y=y, sr=sr, n_mfcc=12, hop_length=hop_length)
# 检测边界
bounds = librosa.segment.agglomerative(mfcc, k=min(8, int(total_duration / 20)))
bound_times = librosa.frames_to_time(bounds, sr=sr, hop_length=hop_length).tolist()
# 加首尾
segment_boundaries = sorted(set([0.0] + bound_times + [total_duration]))
except Exception:
# 降级:按 20s 均匀分段
n_segs = max(3, int(total_duration / 20))
segment_boundaries = [i * total_duration / n_segs for i in range(n_segs + 1)]
# 计算每个段落的平均能量分数
sections_raw = []
for i in range(len(segment_boundaries) - 1):
seg_start = segment_boundaries[i]
seg_end = segment_boundaries[i + 1]
if seg_end - seg_start < 2.0:
continue
# 找该时间范围内的帧
mask = (frame_times >= seg_start) & (frame_times < seg_end)
if mask.sum() == 0:
continue
seg_score = float(energy_score[mask].mean())
sections_raw.append({
"start": round(seg_start, 2),
"end": round(seg_end, 2),
"energy_score": round(seg_score, 4),
})
# 按能量排序,加标签
sections_raw.sort(key=lambda x: x["energy_score"], reverse=True)
labels = ["高能段(副歌/高潮)", "次高能段", "中能段", "低能段(前奏/间奏)"]
sections = []
for idx, s in enumerate(sections_raw):
s["label"] = labels[min(idx, len(labels) - 1)]
sections.append(s)
# ── 4. 为每个目标时长计算最佳裁剪窗口 ───────────────────────────
trim_options = []
frame_count = len(energy_score)
for target_dur in target_durations:
if target_dur >= total_duration:
# 目标时长超过音频总长,直接用全曲
trim_options.append({
"target_duration": target_dur,
"start": 0.0,
"end": round(total_duration, 2),
"actual_duration": round(total_duration, 2),
"energy_score": round(float(energy_score.mean()), 4),
"description": "使用完整音频(目标时长超过总时长)"
})
continue
# 滑动窗口:找窗口内平均能量最高的起始点
window_frames = int(target_dur * sr / hop_length)
best_score = -1.0
best_start_frame = 0
# 步长取 0.5s 对应的帧数,平衡精度与速度
step = max(1, int(0.5 * sr / hop_length))
for start_f in range(0, frame_count - window_frames, step):
end_f = start_f + window_frames
window_score = float(energy_score[start_f:end_f].mean())
if window_score > best_score:
best_score = window_score
best_start_frame = start_f
best_start_time = float(frame_times[best_start_frame])
best_end_time = min(best_start_time + target_dur, total_duration)
# 生成描述
# 判断该窗口是否覆盖了能量峰值
covers_peak = best_start_time <= energy_peak <= best_end_time
if covers_peak:
desc = f"覆盖最强爆发点({energy_peak:.1f}s),情绪张力最高"
elif best_score >= 0.7:
desc = "高能副歌核心区域"
elif best_score >= 0.5:
desc = "中高能量段落"
else:
desc = "最优可用段落"
trim_options.append({
"target_duration": target_dur,
"start": round(best_start_time, 2),
"end": round(best_end_time, 2),
"actual_duration": round(best_end_time - best_start_time, 2),
"energy_score": round(best_score, 4),
"description": desc
})
# 按目标时长排序
trim_options.sort(key=lambda x: x["target_duration"])
# ── 5. BPM(快速估算)────────────────────────────────────────────
try:
import numpy as np
tempo_val = librosa.beat.tempo(y=y, sr=sr)
bpm = round(float(np.asarray(tempo_val).flat[0]), 2)
except Exception:
bpm = None
return {
"total_duration": round(total_duration, 2),
"bpm": bpm,
"energy_peak": round(energy_peak, 2),
"sections": sections,
"trim_options": trim_options,
}
def main():
parser = argparse.ArgumentParser(description="Audio energy analysis for beat-sync-editor")
parser.add_argument("audio_file", help="Path to audio file")
parser.add_argument(
"--targets",
type=str,
default="15,30,60",
help="Target durations in seconds, comma-separated (default: 15,30,60)"
)
parser.add_argument("--top-n", type=int, default=1, dest="top_n")
parser.add_argument("--out", choices=["json", "text"], default="json")
args = parser.parse_args()
try:
target_durations = [int(x.strip()) for x in args.targets.split(",") if x.strip()]
except ValueError:
print("ERROR: --targets must be comma-separated integers (e.g. '15,30,60')", file=sys.stderr)
sys.exit(1)
result = analyze_energy(
audio_path=args.audio_file,
target_durations=target_durations,
top_n=args.top_n,
)
if args.out == "text":
print(f"总时长: {result['total_duration']}s | BPM: {result['bpm']}")
print(f"能量峰值点: {result['energy_peak']}s")
print("\n=== 裁剪方案 ===")
for opt in result["trim_options"]:
print(f" [{opt['target_duration']}s] {opt['start']}s → {opt['end']}s | 张力:{opt['energy_score']:.2f} | {opt['description']}")
else:
print(json.dumps(result, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()