#!/usr/bin/env python3 """ energy_analyze.py — 音频情绪张力分析,找出能量最高的片段 用于 beat-sync-editor skill 的音频预处理步骤: 识别音频中情绪张力最强的区域,并计算若干目标时长下的最佳裁剪窗口。 Usage: python energy_analyze.py [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()