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>
215 lines
9.5 KiB
Python
215 lines
9.5 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
ECAPA-TDNN 声纹搜索 — 多线程版本。
|
|
模型已缓存在 ~/.cache/huggingface,不再重复下载。
|
|
"""
|
|
import os, sys, shutil, json, time, logging
|
|
from multiprocessing import Pool, cpu_count
|
|
import numpy as np
|
|
import torch
|
|
import torchaudio
|
|
|
|
SEARCH_DIR = r"D:\Project\Code\Uni\Cyrene-Voice-Model\data\cleaned"
|
|
OUT_DIR = r"D:\Project\Code\Uni\Cyrene-Voice-Model\data\cyrene_ecapa"
|
|
WORKERS = max(1, cpu_count() - 1)
|
|
os.environ.setdefault("HF_ENDPOINT", "https://hf-mirror.com")
|
|
|
|
CONFIRMED = [
|
|
"External_del_3.5_chapter_2/External_del_3.5_chapter_2_0358_02ff22a9.wav",
|
|
"External_del_3.5_chapter_2/External_del_3.5_chapter_2_0369_0315d90a.wav",
|
|
"External_del_3.5_chapter_2/External_del_3.5_chapter_2_0370_0317b5bf.wav",
|
|
"External_del_3.5_chapter_2/External_del_3.5_chapter_2_0391_033cbeea.wav",
|
|
"External_del_3.5_chapter_2/External_del_3.5_chapter_2_0392_033d9e2e.wav",
|
|
"External_del_3.5_chapter_2/External_del_3.5_chapter_2_0398_0346fa30.wav",
|
|
"External_del_3.5_chapter_2/External_del_3.5_chapter_2_0417_0367cbc5.wav",
|
|
"External_del_3.5_chapter_2/External_del_3.5_chapter_2_0330_02cb864d.wav",
|
|
"External_del_3.5_chapter_2/External_del_3.5_chapter_2_0357_02fd4ab1.wav",
|
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0106_009dfe25.wav",
|
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0107_009fba53.wav",
|
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0112_00a89eb2.wav",
|
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0124_00c08c68.wav",
|
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0128_00c6ba19.wav",
|
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0365_026cf0de.wav",
|
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0371_0278f79f.wav",
|
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0374_02803ab5.wav",
|
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0409_02bcd547.wav",
|
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0410_02bec9f4.wav",
|
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0437_02e57ff0.wav",
|
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0440_02e7d98f.wav",
|
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0467_031daa9e.wav",
|
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0102_0095ba46.wav",
|
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0109_00a458cc.wav",
|
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0126_00c44990.wav",
|
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0130_00c95298.wav",
|
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0359_02670f55.wav",
|
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0364_026b8023.wav",
|
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0366_026f3922.wav",
|
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0369_0274d62a.wav",
|
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0372_027b67ef.wav",
|
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0392_029e92a6.wav",
|
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0413_02c1a418.wav",
|
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0415_02c466f1.wav",
|
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0417_02c8ffc0.wav",
|
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0430_02dbad24.wav",
|
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0431_02dda165.wav",
|
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0434_02e216cf.wav",
|
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0443_02eb0bc4.wav",
|
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0455_03048430.wav",
|
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0462_0316bcd9.wav",
|
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0463_03181c56.wav",
|
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0464_03195142.wav",
|
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0468_031e8bd6.wav",
|
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0469_03208c8a.wav",
|
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0484_03339e50.wav",
|
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0488_033a7664.wav",
|
|
"External_del_3.4_chapter_0/External_del_3.4_chapter_0_0489_033b5c44.wav",
|
|
]
|
|
|
|
|
|
def load_audio(path):
|
|
wav, sr = torchaudio.load(path)
|
|
if sr != 16000:
|
|
wav = torchaudio.functional.resample(wav, sr, 16000)
|
|
if wav.shape[0] > 1:
|
|
wav = wav.mean(dim=0, keepdim=True)
|
|
return wav # [1, samples]
|
|
|
|
|
|
def get_ref_embeddings():
|
|
"""主进程: 加载模型, 提取参考嵌入"""
|
|
from speechbrain.inference.speaker import EncoderClassifier
|
|
classifier = EncoderClassifier.from_hparams(
|
|
source="speechbrain/spkrec-ecapa-voxceleb",
|
|
run_opts={"device": "cpu"},
|
|
)
|
|
refs = []
|
|
for fname in CONFIRMED:
|
|
path = os.path.join(SEARCH_DIR, fname)
|
|
wav = load_audio(path)
|
|
wav = wav.squeeze(0).unsqueeze(0) # [1, time]
|
|
if wav.shape[1] < 16000:
|
|
wav = torch.nn.functional.pad(wav, (0, 16000 - wav.shape[1]))
|
|
with torch.no_grad():
|
|
emb = classifier.encode_batch(wav).squeeze()
|
|
refs.append(emb.numpy())
|
|
return np.mean(refs, axis=0).astype(np.float32)
|
|
|
|
|
|
def scan_chunk(args):
|
|
"""Worker: 加载模型, 扫描一批文件, 返回 [(sim, path), ...]"""
|
|
paths, template_arr = args
|
|
from speechbrain.inference.speaker import EncoderClassifier
|
|
classifier = EncoderClassifier.from_hparams(
|
|
source="speechbrain/spkrec-ecapa-voxceleb",
|
|
run_opts={"device": "cpu"},
|
|
)
|
|
template = torch.from_numpy(template_arr)
|
|
results = []
|
|
for path in paths:
|
|
try:
|
|
wav = load_audio(path)
|
|
wav = wav.squeeze(0).unsqueeze(0)
|
|
if wav.shape[1] < 8000:
|
|
continue
|
|
with torch.no_grad():
|
|
emb = classifier.encode_batch(wav).squeeze()
|
|
sim = torch.nn.functional.cosine_similarity(emb, template, dim=0).item()
|
|
results.append((sim, path))
|
|
except:
|
|
pass
|
|
return results
|
|
|
|
|
|
def main():
|
|
os.makedirs(OUT_DIR, exist_ok=True)
|
|
LOG_FILE = os.path.join(OUT_DIR, "search.log")
|
|
logging.basicConfig(
|
|
level=logging.INFO, format="%(asctime)s %(message)s", datefmt="%H:%M:%S",
|
|
handlers=[logging.FileHandler(LOG_FILE, encoding='utf-8'), logging.StreamHandler(sys.stdout)],
|
|
)
|
|
log = logging.getLogger("ecapa")
|
|
|
|
log.info("ECAPA-TDNN Search (multiprocess)")
|
|
log.info(f" refs: {len(CONFIRMED)} | workers: {WORKERS}")
|
|
|
|
# Step 1: 参考嵌入 (主进程)
|
|
log.info("Extracting reference embeddings...")
|
|
t0 = time.time()
|
|
template = get_ref_embeddings()
|
|
log.info(f" template: dim={len(template)}, pitch proxy={template[0]:.4f} ({time.time()-t0:.0f}s)")
|
|
|
|
# Step 2: 收集文件
|
|
all_wavs = []
|
|
for root, dirs, files in os.walk(SEARCH_DIR):
|
|
for f in files:
|
|
if f.endswith(".wav"):
|
|
all_wavs.append(os.path.join(root, f))
|
|
log.info(f" files: {len(all_wavs):,}")
|
|
|
|
# Step 3: 分块, 多线程扫描
|
|
chunk_size = max(50, len(all_wavs) // (WORKERS * 4))
|
|
chunks = [all_wavs[i:i+chunk_size] for i in range(0, len(all_wavs), chunk_size)]
|
|
chunk_args = [(chunk, template) for chunk in chunks]
|
|
log.info(f" chunks: {len(chunks)} x ~{chunk_size} | starting pool...")
|
|
|
|
t1 = time.time()
|
|
results = []
|
|
done = 0
|
|
pool = Pool(WORKERS)
|
|
for chunk_results in pool.imap_unordered(scan_chunk, chunk_args):
|
|
results.extend(chunk_results)
|
|
done += chunk_size
|
|
elapsed = time.time() - t1
|
|
rate = min(done, len(all_wavs)) / elapsed if elapsed else 0
|
|
eta = (len(all_wavs) - min(done, len(all_wavs))) / rate if rate else 0
|
|
pct = min(done, len(all_wavs)) * 100 / len(all_wavs)
|
|
print(f" [{pct:5.1f}%] {min(done, len(all_wavs)):,}/{len(all_wavs):,} | "
|
|
f"{rate:.0f} f/s | ETA {eta:.0f}s | {len(results):,} ok")
|
|
pool.close()
|
|
pool.join()
|
|
print()
|
|
log.info(f" scanned in {time.time()-t1:.0f}s | {len(results):,} results")
|
|
|
|
# Step 4: 排序 + 输出
|
|
results.sort(key=lambda x: x[0], reverse=True)
|
|
|
|
log.info(f"\n{'='*55}")
|
|
log.info("Top 50 Candidates")
|
|
log.info("=" * 55)
|
|
for rank, (sim, path) in enumerate(results[:50], 1):
|
|
d = os.path.basename(os.path.dirname(path))
|
|
f = os.path.basename(path)
|
|
log.info(f" {rank:2d}. [{sim:.4f}] {d}/{f}")
|
|
|
|
log.info(f"\nSource distribution (sim > 0.65):")
|
|
srcs = {}
|
|
for sim, path in results:
|
|
if sim > 0.65:
|
|
d = os.path.basename(os.path.dirname(path))
|
|
srcs[d] = srcs.get(d, 0) + 1
|
|
for d in sorted(srcs):
|
|
log.info(f" {d}: {srcs[d]}")
|
|
|
|
# Export
|
|
tiers = [("tier1_075", 0.75), ("tier2_070", 0.70), ("tier3_065", 0.65), ("tier4_060", 0.60)]
|
|
for tier_name, thresh in tiers:
|
|
tier_dir = os.path.join(OUT_DIR, tier_name)
|
|
os.makedirs(tier_dir, exist_ok=True)
|
|
n = 0
|
|
for sim, path in results:
|
|
if sim >= thresh:
|
|
dst = os.path.join(tier_dir, os.path.basename(path))
|
|
if os.path.exists(path) and not os.path.exists(dst):
|
|
shutil.copy2(path, dst)
|
|
n += 1
|
|
log.info(f" {tier_name}: {n} files")
|
|
|
|
rp = os.path.join(OUT_DIR, "results.json")
|
|
with open(rp, 'w') as f:
|
|
json.dump([(float(s), p) for s, p in results], f, ensure_ascii=False)
|
|
log.info(f"\n DONE ({time.time()-t0:.0f}s) | {rp}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|