41f653b672
- 所有对外称呼从 QQ 改为 OBv11(注释/提示词/日志/配置项) - 新增 PlatformFormat 结构体,统一管理平台消息标记格式 - defaultPlatformFormats() 注册表替代硬编码 qqTargetRe - extractProactiveMessage 改为 Thinker 方法,遍历格式注册表匹配 - 配置项重命名: QQ_BOT_PORT → OBV11_BOT_PORT, QQBotPort → OBv11BotPort - 标记格式: 【QQ群聊】→【OBv11群聊】、【QQ私聊】→【OBv11私聊】 Co-Authored-By: Claude <noreply@anthropic.com>
131 lines
4.8 KiB
Python
131 lines
4.8 KiB
Python
#!/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}")
|