Add realtime voiceprint speaker matching

This commit is contained in:
Xulilong 2026-07-16 10:01:13 +08:00
parent 67edea1b4b
commit 3e4c34e474
7 changed files with 260 additions and 20 deletions

View File

@ -100,6 +100,8 @@ TEXT_MODEL_API_KEY=none
VOICEPRINT_STORE=/data/asr_voiceprints.json VOICEPRINT_STORE=/data/asr_voiceprints.json
VOICEPRINT_MATCH_THRESHOLD=0.45 VOICEPRINT_MATCH_THRESHOLD=0.45
REALTIME_VOICEPRINT_ENABLED=true
REALTIME_VOICEPRINT_WINDOW_SECONDS=2.0
``` ```
说明: 说明:
@ -109,6 +111,7 @@ VOICEPRINT_MATCH_THRESHOLD=0.45
- `FUNASR_WEBSOCKET_IP` 是 Speakr 后端实时录音代理要连接的 WebSocket 地址。 - `FUNASR_WEBSOCKET_IP` 是 Speakr 后端实时录音代理要连接的 WebSocket 地址。
- `TARGET_WS_URL``fastapi-wss` 再往后连接的 ASR WebSocket 地址。 - `TARGET_WS_URL``fastapi-wss` 再往后连接的 ASR WebSocket 地址。
- `VOICEPRINT_MATCH_THRESHOLD` 是声纹匹配阈值。值越低越容易匹配,值越高越不容易误匹配。 - `VOICEPRINT_MATCH_THRESHOLD` 是声纹匹配阈值。值越低越容易匹配,值越高越不容易误匹配。
- `REALTIME_VOICEPRINT_WINDOW_SECONDS` 是实时声纹匹配窗口,当前为 2 秒;识别抖动时可调到 3-5 秒。
## 运行数据 ## 运行数据

View File

@ -36,10 +36,13 @@ REALTIME_PARTIAL_INTERVAL_SECONDS=2.0
# fastapi_wss legacy/local ASR-with-speaker helper # fastapi_wss legacy/local ASR-with-speaker helper
ASR_LOCAL_URL=http://asr-test:59805/asr ASR_LOCAL_URL=http://asr-test:59805/asr
SPEAKER_DET_URL=http://voice-dected:8000/extract_embedding SPEAKER_DET_URL=http://voice-dected:8000/extract_embedding
REALTIME_VOICEPRINT_ENABLED=true
# Runtime REALTIME_VOICEPRINT_WINDOW_SECONDS=2.0
DEVICE=cuda REALTIME_VOICEPRINT_MIN_SECONDS=1.2
# Runtime
DEVICE=cuda
CUDA_VISIBLE_DEVICES=0 CUDA_VISIBLE_DEVICES=0
NVIDIA_VISIBLE_DEVICES=7 NVIDIA_VISIBLE_DEVICES=7
PYTHONUNBUFFERED=1 PYTHONUNBUFFERED=1

View File

@ -193,12 +193,18 @@ services:
environment: environment:
TARGET_WS_URL: ws://funasr-runtime-2pass:10095 TARGET_WS_URL: ws://funasr-runtime-2pass:10095
REDIS_URL: redis://redis:6379/0 REDIS_URL: redis://redis:6379/0
volumes: VOICEPRINT_STORE: /data/asr/asr_voiceprints.json
- ./fastapi_wss:/app SPEAKER_DET_URL: http://voice-dected:8000/extract_embedding
ports: REALTIME_VOICEPRINT_ENABLED: "true"
- "10095:10095" REALTIME_VOICEPRINT_WINDOW_SECONDS: "2.0"
volumes:
- ./fastapi_wss:/app
- ./deploy/data/asr:/data/asr:ro
ports:
- "10095:10095"
depends_on: depends_on:
- funasr-runtime-2pass - funasr-runtime-2pass
- voice-dected
- redis - redis
command: command:
- python - python

View File

@ -15,14 +15,21 @@ WebSocket 智能语义分析服务
import asyncio import asyncio
import json import json
import math
import os import os
import time
import uuid
import wave
import websockets import websockets
import re import re
import logging import logging
from io import BytesIO
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
from typing import List, Optional, Dict, Set from typing import List, Optional, Dict, Set
from dataclasses import dataclass from dataclasses import dataclass
from urllib import request as urllib_request
from urllib.error import URLError, HTTPError
from dotenv import load_dotenv from dotenv import load_dotenv
try: try:
@ -136,6 +143,18 @@ LLM_ROLE_HISTORY_MAX_CHARS = int(get_env("LLM_ROLE_HISTORY_MAX_CHARS", "1200"))
# 候选说话人列表(用于身份识别 Agent # 候选说话人列表(用于身份识别 Agent
CANDIDATE_SPEAKERS = ["局长", "主持人", "商务专员", "市场专员", "控制要素席", "空中侦察席"] CANDIDATE_SPEAKERS = ["局长", "主持人", "商务专员", "市场专员", "控制要素席", "空中侦察席"]
# 实时声纹匹配配置。文字实时转写不等待声纹;声纹结果稍后回填到对应句子。
REALTIME_VOICEPRINT_ENABLED = get_env_bool("REALTIME_VOICEPRINT_ENABLED", True)
REALTIME_VOICEPRINT_WINDOW_SECONDS = float(get_env("REALTIME_VOICEPRINT_WINDOW_SECONDS", "2.0"))
REALTIME_VOICEPRINT_MIN_SECONDS = float(get_env("REALTIME_VOICEPRINT_MIN_SECONDS", "1.2"))
REALTIME_VOICEPRINT_BUFFER_SECONDS = float(get_env("REALTIME_VOICEPRINT_BUFFER_SECONDS", "8.0"))
REALTIME_VOICEPRINT_SAMPLE_RATE = int(get_env("REALTIME_VOICEPRINT_SAMPLE_RATE", "16000"))
REALTIME_VOICEPRINT_CHANNELS = int(get_env("REALTIME_VOICEPRINT_CHANNELS", "1"))
REALTIME_VOICEPRINT_SAMPLE_WIDTH = int(get_env("REALTIME_VOICEPRINT_SAMPLE_WIDTH", "2"))
VOICEPRINT_STORE = get_env("VOICEPRINT_STORE", "/data/asr/asr_voiceprints.json")
VOICEPRINT_MATCH_THRESHOLD = float(get_env("VOICEPRINT_MATCH_THRESHOLD", "0.45"))
SPEAKER_DET_URL = get_env("SPEAKER_DET_URL", "http://voice-dected:8000/extract_embedding")
# ======================== # ========================
# 3. Redis 管理器 # 3. Redis 管理器
# ======================== # ========================
@ -606,9 +625,7 @@ class A2AOrchestrator:
def build_acoustic_speaker_message(data: Dict) -> Optional[Dict]: def build_acoustic_speaker_message(data: Dict) -> Optional[Dict]:
text = (data.get("text") or "").strip() text = (data.get("text") or "").strip()
speaker = (data.get("spk_name") or "").strip() speaker = (data.get("spk_name") or "").strip()
if not speaker or speaker.lower() == "unknown": if not text or not speaker or speaker.lower() == "unknown":
speaker = get_env("REALTIME_DEFAULT_SPEAKER", "SPEAKER_00")
if not text or not speaker:
return None return None
segment = { segment = {
@ -638,6 +655,193 @@ def build_acoustic_speaker_message(data: Dict) -> Optional[Dict]:
return msg return msg
def build_voiceprint_speaker_message(data: Dict, speaker: str, score: float) -> Optional[Dict]:
text = (data.get("text") or "").strip()
if not text or not speaker:
return None
segment = {
"id": 0,
"speaker": speaker,
"embedding": None,
"seek": 0,
"full_text": text,
"start": None,
"end": None,
}
msg = {
"mode": "offline-speaker",
"type": "asr_with_speaker",
"source": "voiceprint",
"speaker": speaker,
"spk_score": score,
"text": text,
"wav_name": data.get("wav_name"),
"is_final": data.get("is_final", True),
"segments": [segment],
}
if "timestamp" in data:
msg["timestamp"] = data["timestamp"]
return msg
class RollingPcmBuffer:
def __init__(self, max_seconds: float):
self.bytes_per_second = (
REALTIME_VOICEPRINT_SAMPLE_RATE
* REALTIME_VOICEPRINT_CHANNELS
* REALTIME_VOICEPRINT_SAMPLE_WIDTH
)
self.max_bytes = max(int(max_seconds * self.bytes_per_second), self.bytes_per_second)
self._buf = bytearray()
self._lock = asyncio.Lock()
async def append(self, chunk: bytes):
if not chunk:
return
async with self._lock:
self._buf.extend(chunk)
overflow = len(self._buf) - self.max_bytes
if overflow > 0:
del self._buf[:overflow]
async def snapshot(self, seconds: float) -> bytes:
want = max(0, int(seconds * self.bytes_per_second))
async with self._lock:
if want <= 0 or want >= len(self._buf):
return bytes(self._buf)
return bytes(self._buf[-want:])
class VoiceprintMatcher:
def __init__(self):
self.store_path = Path(VOICEPRINT_STORE)
self.match_threshold = VOICEPRINT_MATCH_THRESHOLD
self._mtime = None
self._profiles: Dict[str, List[float]] = {}
def _load_profiles(self) -> Dict[str, List[float]]:
try:
stat = self.store_path.stat()
except FileNotFoundError:
logger.warning("实时声纹库不存在: %s", self.store_path)
self._profiles = {}
self._mtime = None
return self._profiles
if self._mtime == stat.st_mtime and self._profiles:
return self._profiles
try:
data = json.loads(self.store_path.read_text(encoding="utf-8"))
except Exception as exc:
logger.error("读取实时声纹库失败: %s", exc, exc_info=True)
self._profiles = {}
self._mtime = stat.st_mtime
return self._profiles
profiles: Dict[str, List[float]] = {}
containers = data.values() if isinstance(data, dict) else data
if isinstance(containers, dict):
containers = containers.values()
for container in containers:
if isinstance(container, dict) and "embedding" in container:
name = container.get("name") or container.get("voice_name")
emb = container.get("embedding")
if name and isinstance(emb, list):
profiles[str(name)] = emb
elif isinstance(container, dict):
for name, item in container.items():
if isinstance(item, dict) and isinstance(item.get("embedding"), list):
profiles[str(item.get("name") or name)] = item["embedding"]
self._profiles = profiles
self._mtime = stat.st_mtime
logger.info("实时声纹库已加载: %d 个说话人", len(profiles))
return profiles
@staticmethod
def _pcm_to_wav_bytes(pcm: bytes) -> bytes:
bio = BytesIO()
with wave.open(bio, "wb") as wf:
wf.setnchannels(REALTIME_VOICEPRINT_CHANNELS)
wf.setsampwidth(REALTIME_VOICEPRINT_SAMPLE_WIDTH)
wf.setframerate(REALTIME_VOICEPRINT_SAMPLE_RATE)
wf.writeframes(pcm)
return bio.getvalue()
@staticmethod
def _cosine(a: List[float], b: List[float]) -> float:
if not a or not b or len(a) != len(b):
return -1.0
dot = sum(float(x) * float(y) for x, y in zip(a, b))
na = math.sqrt(sum(float(x) * float(x) for x in a))
nb = math.sqrt(sum(float(y) * float(y) for y in b))
if na <= 0 or nb <= 0:
return -1.0
return dot / (na * nb)
def _extract_embedding_sync(self, pcm: bytes) -> Optional[List[float]]:
wav_bytes = self._pcm_to_wav_bytes(pcm)
boundary = "----voiceprint-%s" % uuid.uuid4().hex
body = BytesIO()
body.write(f"--{boundary}\r\n".encode())
body.write(b'Content-Disposition: form-data; name="wav_file"; filename="realtime.wav"\r\n')
body.write(b"Content-Type: audio/wav\r\n\r\n")
body.write(wav_bytes)
body.write(f"\r\n--{boundary}--\r\n".encode())
req = urllib_request.Request(
SPEAKER_DET_URL,
data=body.getvalue(),
headers={"Content-Type": f"multipart/form-data; boundary={boundary}"},
method="POST",
)
with urllib_request.urlopen(req, timeout=8) as resp:
payload = json.loads(resp.read().decode("utf-8"))
emb = payload.get("embedding")
return emb if isinstance(emb, list) else None
async def match_pcm(self, pcm: bytes) -> Optional[Dict]:
min_bytes = int(
REALTIME_VOICEPRINT_MIN_SECONDS
* REALTIME_VOICEPRINT_SAMPLE_RATE
* REALTIME_VOICEPRINT_CHANNELS
* REALTIME_VOICEPRINT_SAMPLE_WIDTH
)
if len(pcm) < min_bytes:
return None
profiles = self._load_profiles()
if not profiles:
return None
try:
embedding = await asyncio.to_thread(self._extract_embedding_sync, pcm)
except (HTTPError, URLError, TimeoutError, OSError) as exc:
logger.warning("实时声纹提取失败: %s", exc)
return None
except Exception as exc:
logger.error("实时声纹提取异常: %s", exc, exc_info=True)
return None
if not embedding:
return None
best_name = None
best_score = -1.0
for name, profile_embedding in profiles.items():
score = self._cosine(embedding, profile_embedding)
if score > best_score:
best_name = name
best_score = score
if best_name and best_score >= self.match_threshold:
logger.info("实时声纹匹配: %s score=%.4f", best_name, best_score)
return {"speaker": best_name, "score": best_score}
logger.info("实时声纹未匹配: best=%s score=%.4f threshold=%.4f", best_name, best_score, self.match_threshold)
return None
MOJIBAKE_MARKERS = ("", "", "", "", "", "", "", "", "", "", "") MOJIBAKE_MARKERS = ("", "", "", "", "", "", "", "", "", "", "")
@ -850,6 +1054,8 @@ async def forward_audio(websocket):
# 用于追踪后台任务,防止连接关闭时任务被强制销毁 # 用于追踪后台任务,防止连接关闭时任务被强制销毁
background_tasks: Set[asyncio.Task] = set() background_tasks: Set[asyncio.Task] = set()
audio_buffer = RollingPcmBuffer(REALTIME_VOICEPRINT_BUFFER_SECONDS)
voiceprint_matcher = VoiceprintMatcher() if REALTIME_VOICEPRINT_ENABLED else None
try: try:
# 连接到目标 ASR 服务 # 连接到目标 ASR 服务
@ -873,6 +1079,8 @@ async def forward_audio(websocket):
""" """
try: try:
async for message in websocket: async for message in websocket:
if isinstance(message, (bytes, bytearray)):
await audio_buffer.append(bytes(message))
await target_ws.send(message) await target_ws.send(message)
except websockets.exceptions.ConnectionClosed: except websockets.exceptions.ConnectionClosed:
logger.info("客户端连接已关闭,停止上行转发") logger.info("客户端连接已关闭,停止上行转发")
@ -897,6 +1105,27 @@ async def forward_audio(websocket):
except Exception as e: except Exception as e:
logger.error(f"发送LLM角色结果失败: {e}", exc_info=True) logger.error(f"发送LLM角色结果失败: {e}", exc_info=True)
async def send_voiceprint_update(asr_data: Dict):
if voiceprint_matcher is None:
return
pcm = await audio_buffer.snapshot(REALTIME_VOICEPRINT_WINDOW_SECONDS)
match = await voiceprint_matcher.match_pcm(pcm)
if not match:
return
speaker_msg = build_voiceprint_speaker_message(
asr_data,
match["speaker"],
match["score"],
)
if speaker_msg is None:
return
try:
await websocket.send(json.dumps(speaker_msg, ensure_ascii=False))
except websockets.exceptions.ConnectionClosed:
logger.info("客户端连接已关闭,停止发送实时声纹结果")
except Exception as exc:
logger.error("发送实时声纹结果失败: %s", exc, exc_info=True)
async def target_to_frontend(): async def target_to_frontend():
""" """
下行转发ASR 识别结果 -> 前端 下行转发ASR 识别结果 -> 前端
@ -924,6 +1153,10 @@ async def forward_audio(websocket):
t = asyncio.create_task(send_role_update(speaker_msg)) t = asyncio.create_task(send_role_update(speaker_msg))
background_tasks.add(t) background_tasks.add(t)
t.add_done_callback(background_tasks.discard) t.add_done_callback(background_tasks.discard)
if voiceprint_matcher is not None:
t = asyncio.create_task(send_voiceprint_update(data))
background_tasks.add(t)
t.add_done_callback(background_tasks.discard)
except websockets.exceptions.ConnectionClosed: except websockets.exceptions.ConnectionClosed:
logger.info("客户端连接已关闭,停止发送增强结果") logger.info("客户端连接已关闭,停止发送增强结果")
break break

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1125,7 +1125,7 @@ export class RealtimeAsrPipeline {
let lastRenderedSpeaker = initialSpeaker; let lastRenderedSpeaker = initialSpeaker;
let lastContentChar = ""; let lastContentChar = "";
segments.forEach((segment, index) => { segments.forEach((segment) => {
if (this.getShowSpeaker()) { if (this.getShowSpeaker()) {
if ( if (
segment.speaker && segment.speaker &&
@ -1153,9 +1153,6 @@ export class RealtimeAsrPipeline {
lastContentChar lastContentChar
); );
displayText += segText; displayText += segText;
if (index < segments.length - 1 && !/\n\s*$/.test(displayText)) {
displayText += "\n\n";
}
// 跟踪上一个 segment 的末尾非空白字符,传给下一次 handleWithTimestamp // 跟踪上一个 segment 的末尾非空白字符,传给下一次 handleWithTimestamp
const trimmed = segText.replace(/\s+$/, ""); const trimmed = segText.replace(/\s+$/, "");