From b41cb09db5c9a7878e34222f85b14a858122267b Mon Sep 17 00:00:00 2001 From: yumoqing Date: Mon, 27 Jul 2026 16:08:33 +0800 Subject: [PATCH] init: ECAPA-TDNN voiceprint embedding service --- .gitignore | 6 +++++ ahserver | 1 + app/voiceprint_app.py | 12 +++++++++ appPublic | 1 + conf/config.json | 33 +++++++++++++++++++++++ download_model.py | 24 +++++++++++++++++ longtasks | 1 + sqlor | 1 + start.sh | 8 ++++++ stop.sh | 9 +++++++ vp/__init__.py | 7 +++++ vp/engine.py | 63 +++++++++++++++++++++++++++++++++++++++++++ 12 files changed, 166 insertions(+) create mode 100644 .gitignore create mode 160000 ahserver create mode 100644 app/voiceprint_app.py create mode 160000 appPublic create mode 100644 conf/config.json create mode 100644 download_model.py create mode 160000 longtasks create mode 160000 sqlor create mode 100755 start.sh create mode 100644 stop.sh create mode 100644 vp/__init__.py create mode 100644 vp/engine.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..837d892 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +venv/ +logs/ +__pycache__/ +*.pyc +*.pid +*.log diff --git a/ahserver b/ahserver new file mode 160000 index 0000000..6165795 --- /dev/null +++ b/ahserver @@ -0,0 +1 @@ +Subproject commit 6165795aa742d7539c758ff4ef370d24f3caaee7 diff --git a/app/voiceprint_app.py b/app/voiceprint_app.py new file mode 100644 index 0000000..8902c9d --- /dev/null +++ b/app/voiceprint_app.py @@ -0,0 +1,12 @@ +def extract_embedding(audio_path): + signal = MODEL.load_audio(audio_path) + if isinstance(signal, tuple): + signal = signal[0] + signal = signal.unsqueeze(0).to(DEVICE) + with torch.no_grad(): + emb = MODEL.encode_batch(signal) + return emb.squeeze().cpu().numpy().tolist() + +def verify_speakers(audio_path, ref_path): + score, pred = MODEL.verify_files(audio_path, ref_path) + return float(score), bool(pred) \ No newline at end of file diff --git a/appPublic b/appPublic new file mode 160000 index 0000000..371fe76 --- /dev/null +++ b/appPublic @@ -0,0 +1 @@ +Subproject commit 371fe768cb28525ade1cab87c2f51b8e118a8fb3 diff --git a/conf/config.json b/conf/config.json new file mode 100644 index 0000000..d83ca96 --- /dev/null +++ b/conf/config.json @@ -0,0 +1,33 @@ +{ + "device": "cuda:1", + "redis_url": "redis://127.0.0.1:6379", + "worker_cnt": 2, + "logger": { + "name": "voiceprint", + "levelname": "info", + "logfile": "$[workdir]$/logs/voiceprint.log" + }, + "website": { + "paths": [ + ["$[workdir]$/wwwroot", ""] + ], + "client_max_size": 10000, + "host": "0.0.0.0", + "port": 9087, + "coding": "utf-8", + "indexes": ["index.html"], + "startswiths": [ + {"leading": "/api/voiceprint-submit", "registerfunction": "voiceprint_submit"}, + {"leading": "/api/voiceprint-status", "registerfunction": "voiceprint_status"}, + {"leading": "/api/voiceprint/extract", "registerfunction": "voiceprint_extract"}, + {"leading": "/api/voiceprint/verify", "registerfunction": "voiceprint_verify"} + ], + "processors": [ + [".tmpl", "tmpl"], + [".ui", "bui"], + [".dspy", "dspy"] + ], + "session_max_time": 3000, + "session_issue_time": 2500 + } +} diff --git a/download_model.py b/download_model.py new file mode 100644 index 0000000..dcd78cb --- /dev/null +++ b/download_model.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +"""Download ECAPA-TDNN model via ModelScope to /share/models/ecapa-tdnn/""" +import os, sys, time + +MODEL_ID = 'iic/speech_ecapa-tdnn_sv_en_voxceleb_16k' +TARGET = '/share/models/ecapa-tdnn' + +os.makedirs(TARGET, exist_ok=True) + +# Try ModelScope first +for attempt in range(3): + try: + from modelscope import snapshot_download + print(f"Attempt {attempt+1}: ModelScope download...") + snapshot_download(MODEL_ID, local_dir=TARGET) + print("SUCCESS via ModelScope") + sys.exit(0) + except Exception as e: + print(f"ModelScope failed: {e}") + time.sleep(10) + +# Fallback: try HF mirror +print("Trying HF mirror...") +os.system(f'export HF_ENDPOINT=https://hf-mirror.com && python3 -c "from huggingface_hub import snapshot_download; snapshot_download(\'speechbrain/spkrec-ecapa-voxceleb\', local_dir=\'{TARGET}\')"') diff --git a/longtasks b/longtasks new file mode 160000 index 0000000..3497f30 --- /dev/null +++ b/longtasks @@ -0,0 +1 @@ +Subproject commit 3497f30c96b4b8beee551a8f2c2144e3581edd73 diff --git a/sqlor b/sqlor new file mode 160000 index 0000000..a9a02eb --- /dev/null +++ b/sqlor @@ -0,0 +1 @@ +Subproject commit a9a02eb45bf8b0f5fa17c859eb4478c93a42a0e8 diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..8ad74a1 --- /dev/null +++ b/start.sh @@ -0,0 +1,8 @@ +#!/bin/bash +cd "$(dirname "$0")" +mkdir -p logs +setsid python3 app/voiceprint_app.py -p 9087 >> logs/voiceprint.log 2>&1 & +echo $! > voiceprint.pid +echo "Voiceprint PID=$(cat voiceprint.pid)" +sleep 3 +curl -s http://localhost:9087/api/health && echo "" || echo "(checking...)" diff --git a/stop.sh b/stop.sh new file mode 100644 index 0000000..c4bc6d1 --- /dev/null +++ b/stop.sh @@ -0,0 +1,9 @@ +#!/bin/bash +cd "$(dirname "$0")" +if [ -f voiceprint.pid ]; then + kill $(cat voiceprint.pid) 2>/dev/null + rm -f voiceprint.pid + echo "Voiceprint stopped" +else + pkill -f voiceprint_app.py 2>/dev/null && echo "Voiceprint stopped (via pkill)" +fi diff --git a/vp/__init__.py b/vp/__init__.py new file mode 100644 index 0000000..6e513b9 --- /dev/null +++ b/vp/__init__.py @@ -0,0 +1,7 @@ +from ahserver.serverenv import ServerEnv +from vp.engine import VoiceprintEngine + + +def load_voiceprint(): + env = ServerEnv() + env.voiceprint = VoiceprintEngine() diff --git a/vp/engine.py b/vp/engine.py new file mode 100644 index 0000000..30ea7bb --- /dev/null +++ b/vp/engine.py @@ -0,0 +1,63 @@ +"""ECAPA-TDNN Voiceprint Engine — loads model, processes extract/verify tasks.""" +import torch +from longtasks.longtasks import LongTasks +from appPublic.worker import awaitify +from appPublic.jsonConfig import getConfig +from appPublic.log import debug +from speechbrain.inference.speaker import SpeakerRecognition +import numpy as np + + +class VoiceprintEngine(LongTasks): + def __init__(self): + self.config = getConfig() + super().__init__(self.config.redis_url, 'voiceprint', worker_cnt=self.config.worker_cnt) + self.load_model() + + def load_model(self): + device = self.config.device + debug(f'loading ECAPA-TDNN on {device}...') + self.verifier = SpeakerRecognition.from_hparams( + source="speechbrain/spkrec-ecapa-voxceleb", + savedir="/share/models/ecapa-tdnn", + run_opts={"device": device} + ) + debug('ECAPA-TDNN loaded') + + async def process_task(self, payload, workerid=None): + task_type = payload.get('task_type', 'extract') + audio_file = payload.get('audio_file', '') + if not audio_file: + return {'status': 'FAILED', 'result': 'missing audio_file'} + + if task_type == 'extract': + f = awaitify(self._extract) + return await f(audio_file) + elif task_type == 'verify': + ref_file = payload.get('reference_file', '') + if not ref_file: + return {'status': 'FAILED', 'result': 'missing reference_file'} + f = awaitify(self._verify) + return await f(audio_file, ref_file) + return {'status': 'FAILED', 'result': f'unknown task_type: {task_type}'} + + def _extract(self, audio_path): + signal = self.verifier.load_audio(audio_path, 16000) + t = torch.tensor(signal).unsqueeze(0).to(self.config.device) + emb = self.verifier.encode_batch(t) + vec = emb.squeeze().cpu().numpy().tolist() + return { + 'status': 'SUCCEEDED', + 'embedding': vec, + 'embedding_dim': len(vec), + 'usage': {'audio_duration': round(len(signal) / 16000, 2)}, + } + + def _verify(self, audio_path, ref_path): + score, pred = self.verifier.verify_files(audio_path, ref_path) + return { + 'status': 'SUCCEEDED', + 'similarity': round(float(score), 4), + 'is_same_speaker': bool(pred), + 'usage': {}, + }