#!/usr/bin/env python3 """ 声纹聚类:使用 pitch + delta-MFCC + 谱特征找出相似声音。 """ import os, sys, json, warnings import numpy as np import librosa warnings.filterwarnings('ignore') REF_FILE = r"D:\Project\Code\Uni\Cyrene-Voice-Model\data\cleaned\VoBanks29\VoBanks29_0036_001a1127.wav" SEARCH_DIR = r"D:\Project\Code\Uni\Cyrene-Voice-Model\data\cleaned" def extract_voiceprint(wav_path): """提取多维度声纹特征向量""" try: y, sr = librosa.load(wav_path, sr=22050, mono=True) if len(y) < sr * 0.3: return None # 1. Pitch (F0) 统计 — 最区分说话人的特征 f0, voiced_flag, _ = librosa.pyin(y, fmin=80, fmax=600, sr=sr) f0 = f0[~np.isnan(f0)] if len(f0) < 10: return None pitch_features = [ np.mean(f0), np.std(f0), np.percentile(f0, 10), np.percentile(f0, 25), np.percentile(f0, 50), np.percentile(f0, 75), np.percentile(f0, 90), ] # 2. MFCC delta (语音动态特征) mfcc = librosa.feature.mfcc(y=y, sr=sr, n_mfcc=13) mfcc_delta = librosa.feature.delta(mfcc) mfcc_delta2 = librosa.feature.delta(mfcc, order=2) mfcc_features = np.concatenate([ np.mean(mfcc, axis=1), np.std(mfcc, axis=1), np.mean(mfcc_delta, axis=1), np.std(mfcc_delta, axis=1), np.mean(mfcc_delta2, axis=1), np.std(mfcc_delta2, axis=1), ]) # 3. 频谱特征 spectral = librosa.feature.spectral_centroid(y=y, sr=sr) rolloff = librosa.feature.spectral_rolloff(y=y, sr=sr) spec_features = [ np.mean(spectral), np.std(spectral), np.mean(rolloff), np.std(rolloff), ] return np.concatenate([pitch_features, mfcc_features, spec_features]) except Exception: return None def cosine_sim(a, b): return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-8) print("提取参考音频特征...") ref_feat = extract_voiceprint(REF_FILE) if ref_feat is None: print("错误: 无法提取参考音频特征") sys.exit(1) print(f" 特征维度: {len(ref_feat)}") print(f" Pitch: mean={ref_feat[0]:.1f}Hz std={ref_feat[1]:.1f}Hz") # 收集文件 wav_files = [] for root, dirs, files in os.walk(SEARCH_DIR): for f in files: if f.endswith('.wav') and 'VoBanks' in root: wav_files.append(os.path.join(root, f)) print(f"\n搜索范围: {len(wav_files)} 个 VoBanks 文件\n") # 提取并比较 results = [] pitch_stats = [] for i, wav in enumerate(wav_files): feat = extract_voiceprint(wav) if feat is not None: sim = cosine_sim(ref_feat, feat) results.append((sim, wav, feat[0])) # feat[0] = mean pitch pitch_stats.append(feat[0]) if (i + 1) % 200 == 0: print(f" 进度: {i+1}/{len(wav_files)}") results.sort(key=lambda x: x[0], reverse=True) # 全局音高分布 all_pitches = [r[2] for r in results] print(f"\n全局音高: mean={np.mean(all_pitches):.0f}Hz, 范围=[{np.min(all_pitches):.0f}, {np.max(all_pitches):.0f}]") print(f"参考音高: {ref_feat[0]:.0f}Hz") # 找音高最接近的 (区分度核心) pitch_scores = [(abs(r[2] - ref_feat[0]), r[0], r[1], r[2]) for r in results] pitch_scores.sort() print(f"\n=== 音高最接近的 Top 20 (参考={ref_feat[0]:.0f}Hz) ===") for rank, (pdiff, sim, path, pitch) in enumerate(pitch_scores[:20], 1): fname = os.path.basename(path) parent = os.path.basename(os.path.dirname(path)) marker = " ★" if sim > 0.98 else "" print(f" {rank:2d}. [{pitch:.0f}Hz Δ={pdiff:.0f} sim={sim:.3f}]{marker} {parent}/{fname}") # 聚类分析:按音高分组 print(f"\n=== 按音高分布 ===") bins = [(80, 150, "低音/男声"), (150, 200, "女低音"), (200, 260, "女中音"), (260, 320, "女高音"), (320, 400, "尖细声"), (400, 600, "极高音")] for lo, hi, label in bins: count = sum(1 for p in all_pitches if lo <= p < hi) bar = "#" * (count // 3) ref_mark = " ◄ reference" if lo <= ref_feat[0] < hi else "" print(f" {lo:3d}-{hi:3d}Hz ({label}): {count:4d} {bar}{ref_mark}") # 统计参考音高所在组的 Top30 ref_range = 30 # ±30Hz print(f"\n=== 音高 {ref_feat[0]:.0f}±{ref_range}Hz 内的文件 ===") close = [(s, p, os.path.basename(p2), os.path.basename(os.path.dirname(p2))) for s, p2, p in results if abs(p - ref_feat[0]) < ref_range] close.sort(key=lambda x: x[0], reverse=True) for rank, (sim, path, fname, parent) in enumerate(close[:30], 1): print(f" {rank:2d}. [{sim:.4f}] {parent}/{fname}") print(f" ... 共 {len(close)} 个文件在 ±{ref_range}Hz 范围内") # 保存 out = os.path.join(os.path.dirname(REF_FILE), 'voice_cluster_results.json') with open(out, 'w') as f: json.dump([(float(s), p, float(pp)) for s, p, pp in results], f) print(f"\n结果已保存: {out}")