|
| 1 | +import numpy as np |
| 2 | +import librosa |
| 3 | +import librosa.display |
| 4 | +import matplotlib.pyplot as plt |
| 5 | +from sklearn.cluster import KMeans |
| 6 | +from sklearn.metrics.pairwise import euclidean_distances |
| 7 | +import json |
| 8 | +import os |
| 9 | + |
| 10 | + |
| 11 | +def detect_chorus_auto(audio_path, sr=22050, n_bins=72, hop_length=512, m=None, visualize=True): |
| 12 | + """自动自适应谱聚类副歌检测""" |
| 13 | + print(f"🎧 加载音频中:{audio_path}") |
| 14 | + y, sr = librosa.load(audio_path, sr=sr, mono=True) |
| 15 | + |
| 16 | + # 🥁 Beat tracking |
| 17 | + tempo, beats = librosa.beat.beat_track(y=y, sr=sr, hop_length=hop_length) |
| 18 | + tempo = float(np.atleast_1d(tempo)[0]) |
| 19 | + beat_times = librosa.frames_to_time(beats, sr=sr, hop_length=hop_length) |
| 20 | + n_beats = len(beats) |
| 21 | + print(f"✅ 检测到 {n_beats} 个节拍, tempo={tempo:.1f} BPM") |
| 22 | + |
| 23 | + if n_beats < 8: |
| 24 | + raise ValueError("节拍太少,无法分析,请尝试另一首歌曲。") |
| 25 | + |
| 26 | + # 自动选择谱聚类维度 |
| 27 | + if m is None: |
| 28 | + m = min(6, max(3, n_beats // 100)) |
| 29 | + print(f"📊 自动选择聚类层次数 m={m}") |
| 30 | + |
| 31 | + # 🎼 特征提取 |
| 32 | + C = np.abs(librosa.cqt(y, sr=sr, hop_length=hop_length, n_bins=n_bins)) |
| 33 | + C_sync = librosa.util.sync(C, beats, aggregate=np.mean).T |
| 34 | + |
| 35 | + M = librosa.feature.mfcc(y=y, sr=sr, hop_length=hop_length, n_mfcc=13) |
| 36 | + M_sync = librosa.util.sync(M, beats, aggregate=np.mean).T |
| 37 | + |
| 38 | + rms = librosa.feature.rms(y=y, frame_length=2048, hop_length=hop_length)[0] |
| 39 | + rms_sync = librosa.util.sync(rms, beats, aggregate=np.mean) |
| 40 | + |
| 41 | + # 📈 高斯相似度 |
| 42 | + def gaussian_affinity(X, k=8): |
| 43 | + dist = euclidean_distances(X, X) |
| 44 | + sigma = np.mean(np.sort(dist, axis=1)[:, k]) |
| 45 | + return np.exp(-dist**2 / (2 * sigma**2 + 1e-8)) |
| 46 | + |
| 47 | + S_rep = gaussian_affinity(C_sync, k=8) |
| 48 | + S_loc = gaussian_affinity(M_sync, k=8) |
| 49 | + n = S_rep.shape[0] |
| 50 | + |
| 51 | + # 🔁 Recurrence matrix (Eq.1) |
| 52 | + k = 8 |
| 53 | + knn = np.argsort(euclidean_distances(C_sync, C_sync), axis=1)[:, 1:k+1] |
| 54 | + R = np.zeros((n, n)) |
| 55 | + for i in range(n): |
| 56 | + for j in knn[i]: |
| 57 | + if i in knn[j]: |
| 58 | + R[i, j] = 1 |
| 59 | + R = np.maximum(R, R.T) |
| 60 | + |
| 61 | + # 平滑 |
| 62 | + w = 3 |
| 63 | + R_prime = np.zeros_like(R) |
| 64 | + for i in range(n): |
| 65 | + for j in range(n): |
| 66 | + votes = [R[i+t, j+t] for t in range(-w, w+1) |
| 67 | + if 0 <= i+t < n and 0 <= j+t < n] |
| 68 | + R_prime[i, j] = np.round(np.mean(votes)) |
| 69 | + |
| 70 | + # 序列连接 + 加权 |
| 71 | + Delta = np.eye(n, k=1) + np.eye(n, k=-1) |
| 72 | + dR = np.sum(R_prime, axis=1) |
| 73 | + dD = np.sum(Delta, axis=1) |
| 74 | + mu = np.dot(dD, dR + dD) / (np.sum((dR + dD)**2) + 1e-8) |
| 75 | + A = mu * (R_prime * S_rep) + (1 - mu) * (Delta * S_loc) |
| 76 | + |
| 77 | + # 拉普拉斯矩阵 |
| 78 | + D = np.diag(np.sum(A, axis=1)) |
| 79 | + D_inv_sqrt = np.diag(1.0 / np.sqrt(np.sum(A, axis=1) + 1e-8)) |
| 80 | + L = np.eye(n) - D_inv_sqrt @ A @ D_inv_sqrt |
| 81 | + L += np.eye(n) * 1e-6 |
| 82 | + |
| 83 | + # 谱分解 |
| 84 | + eigvals, eigvecs = np.linalg.eigh(L) |
| 85 | + eigvecs = eigvecs[:, :m] |
| 86 | + Y = eigvecs / (np.linalg.norm(eigvecs, axis=1, keepdims=True) + 1e-8) |
| 87 | + labels = KMeans(n_clusters=m, n_init=10, random_state=42).fit_predict(Y) |
| 88 | + |
| 89 | + # 找边界 |
| 90 | + boundaries = np.where(np.diff(labels) != 0)[0] |
| 91 | + boundary_times = beat_times[boundaries] |
| 92 | + |
| 93 | + # 🔎 计算每段得分(重复度 + 能量) |
| 94 | + scores = [] |
| 95 | + for k_label in set(labels): |
| 96 | + idx = np.where(labels == k_label)[0] |
| 97 | + submat = R_prime[np.ix_(idx, idx)] |
| 98 | + repeat_score = np.sum(submat) |
| 99 | + energy_score = np.mean(rms_sync[idx]) |
| 100 | + scores.append(repeat_score * 0.7 + energy_score * 0.3) |
| 101 | + |
| 102 | + chorus_label = np.argmax(scores) |
| 103 | + chorus_start_idx = np.where(labels == chorus_label)[0][0] |
| 104 | + chorus_start_time = beat_times[chorus_start_idx] |
| 105 | + |
| 106 | + # 🧠 自动修正:避免前奏误判 |
| 107 | + if chorus_start_time < 15: |
| 108 | + mid = len(beat_times)//3 |
| 109 | + chorus_start_time = float(np.median(beat_times[mid:mid*2])) |
| 110 | + print(f"⚠️ 副歌过早,自动修正到中段 {chorus_start_time:.2f} 秒") |
| 111 | + |
| 112 | + print(f"🎵 最终副歌起点 ≈ {chorus_start_time:.2f} 秒") |
| 113 | + |
| 114 | + # 保存结果 |
| 115 | + out_path = os.path.splitext(audio_path)[0] + "_chorus.json" |
| 116 | + data = { |
| 117 | + "file": os.path.basename(audio_path), |
| 118 | + "chorus_start_sec": float(chorus_start_time), |
| 119 | + "tempo": float(tempo), |
| 120 | + "segments": int(m) |
| 121 | + } |
| 122 | + with open(out_path, "w", encoding="utf-8") as f: |
| 123 | + json.dump(data, f, ensure_ascii=False, indent=2) |
| 124 | + print(f"💾 结果已保存到: {out_path}") |
| 125 | + |
| 126 | + # 可视化 |
| 127 | + if visualize: |
| 128 | + fig, ax = plt.subplots(1, 3, figsize=(15, 4)) |
| 129 | + librosa.display.specshow(R_prime, x_axis='time', y_axis='time', ax=ax[0]) |
| 130 | + ax[0].set_title("Recurrence matrix R′") |
| 131 | + |
| 132 | + ax[1].scatter(range(len(labels)), labels, c=labels, cmap='tab10', s=20) |
| 133 | + ax[1].set_title("Spectral clustering structure") |
| 134 | + ax[1].set_xlabel("Beat index") |
| 135 | + ax[1].set_ylabel("Segment label") |
| 136 | + |
| 137 | + min_len = min(len(beat_times), len(rms_sync)) |
| 138 | + ax[2].plot(beat_times[:min_len], rms_sync[:min_len], color='gray') |
| 139 | + ax[2].axvline(chorus_start_time, color='r', linestyle='--', label='Chorus start') |
| 140 | + ax[2].set_title("RMS Energy vs Time") |
| 141 | + ax[2].set_xlabel("Time (s)") |
| 142 | + ax[2].legend() |
| 143 | + |
| 144 | + plt.tight_layout() |
| 145 | + plt.show() |
| 146 | + |
| 147 | + return chorus_start_time, boundary_times, labels |
| 148 | + |
| 149 | + |
| 150 | +# 🧪 示例调用 |
| 151 | +if __name__ == "__main__": |
| 152 | + chorus_time, boundaries, labels = detect_chorus_auto( |
| 153 | + r"C:\Users\20281\Desktop\music\李荣浩 - 年少有为.mp3", |
| 154 | + visualize=True |
| 155 | + ) |
| 156 | + print(f"\n🎯 副歌起点 ≈ {chorus_time:.2f} 秒") |
0 commit comments