update:python单模块部署声纹识别对接

This commit is contained in:
CGD
2025-07-04 17:11:44 +08:00
parent 04b132816c
commit 3d64152dba
7 changed files with 390 additions and 48 deletions
+9
View File
@@ -137,6 +137,15 @@ plugins:
- ".wav" - ".wav"
- ".p3" - ".p3"
refresh_time: 300 # 刷新音乐列表的时间间隔,单位为秒 refresh_time: 300 # 刷新音乐列表的时间间隔,单位为秒
# 声纹识别配置
voiceprint:
# 声纹接口地址
url:
# 说话人配置:speaker_id,名称,描述
speakers:
- "test1,张三,张三是一个程序员"
- "test2,李四,李四是一个产品经理"
- "test3,王五,王五是一个设计师"
# ##################################################################################### # #####################################################################################
# ################################以下是角色模型配置###################################### # ################################以下是角色模型配置######################################
+27 -2
View File
@@ -595,8 +595,33 @@ class ConnectionHandler:
self.logger.bind(tag=TAG).info(f"大模型收到用户消息: {query}") self.logger.bind(tag=TAG).info(f"大模型收到用户消息: {query}")
self.llm_finish_task = False self.llm_finish_task = False
# 检查是否是JSON格式的消息(包含说话人信息)
enhanced_query = query
try:
if query.strip().startswith('{') and query.strip().endswith('}'):
data = json.loads(query)
if 'speaker' in data and 'content' in data:
# 直接使用JSON格式,不重新格式化
enhanced_query = query
self.logger.bind(tag=TAG).info(f"识别到说话人: {data['speaker']}")
else:
# 如果有说话人信息但不是JSON格式,按原逻辑处理
if hasattr(self, 'current_speaker') and self.current_speaker:
enhanced_query = f"[说话人: {self.current_speaker}] {query}"
self.logger.bind(tag=TAG).info(f"识别到说话人: {self.current_speaker}")
else:
# 如果有说话人信息但不是JSON格式,按原逻辑处理
if hasattr(self, 'current_speaker') and self.current_speaker:
enhanced_query = f"[说话人: {self.current_speaker}] {query}"
self.logger.bind(tag=TAG).info(f"识别到说话人: {self.current_speaker}")
except json.JSONDecodeError:
# JSON解析失败,按原逻辑处理
if hasattr(self, 'current_speaker') and self.current_speaker:
enhanced_query = f"[说话人: {self.current_speaker}] {query}"
self.logger.bind(tag=TAG).info(f"识别到说话人: {self.current_speaker}")
if not tool_call: if not tool_call:
self.dialogue.put(Message(role="user", content=query)) self.dialogue.put(Message(role="user", content=enhanced_query))
# Define intent functions # Define intent functions
functions = None functions = None
@@ -609,7 +634,7 @@ class ConnectionHandler:
memory_str = None memory_str = None
if self.memory is not None: if self.memory is not None:
future = asyncio.run_coroutine_threadsafe( future = asyncio.run_coroutine_threadsafe(
self.memory.query_memory(query), self.loop self.memory.query_memory(enhanced_query), self.loop
) )
memory_str = future.result() memory_str = future.result()
@@ -4,6 +4,7 @@ from core.utils.output_counter import check_device_output_limit
from core.handle.abortHandle import handleAbortMessage from core.handle.abortHandle import handleAbortMessage
import time import time
import asyncio import asyncio
import json
from core.handle.sendAudioHandle import SentenceType from core.handle.sendAudioHandle import SentenceType
from core.utils.util import audio_to_data from core.utils.util import audio_to_data
@@ -38,6 +39,31 @@ async def resume_vad_detection(conn):
async def startToChat(conn, text): async def startToChat(conn, text):
# 检查输入是否是JSON格式(包含说话人信息)
speaker_name = None
actual_text = text
try:
# 尝试解析JSON格式的输入
if text.strip().startswith('{') and text.strip().endswith('}'):
data = json.loads(text)
if 'speaker' in data and 'content' in data:
speaker_name = data['speaker']
actual_text = data['content']
conn.logger.bind(tag=TAG).info(f"解析到说话人信息: {speaker_name}")
# 直接使用JSON格式的文本,不解析
actual_text = text
except (json.JSONDecodeError, KeyError):
# 如果解析失败,继续使用原始文本
pass
# 保存说话人信息到连接对象
if speaker_name:
conn.current_speaker = speaker_name
else:
conn.current_speaker = None
if conn.need_bind: if conn.need_bind:
await check_bind_device(conn) await check_bind_device(conn)
return return
@@ -52,16 +78,16 @@ async def startToChat(conn, text):
if conn.client_is_speaking: if conn.client_is_speaking:
await handleAbortMessage(conn) await handleAbortMessage(conn)
# 首先进行意图分析 # 首先进行意图分析,使用实际文本内容
intent_handled = await handle_user_intent(conn, text) intent_handled = await handle_user_intent(conn, actual_text)
if intent_handled: if intent_handled:
# 如果意图已被处理,不再进行聊天 # 如果意图已被处理,不再进行聊天
return return
# 意图未被处理,继续常规聊天流程 # 意图未被处理,继续常规聊天流程,使用实际文本内容
await send_stt_message(conn, text) await send_stt_message(conn, actual_text)
conn.executor.submit(conn.chat, text) conn.executor.submit(conn.chat, actual_text)
async def no_voice_close_connect(conn, have_voice): async def no_voice_close_connect(conn, have_voice):
@@ -76,7 +76,6 @@ async def sendAudio(conn, audios, pre_buffer=True):
frame_duration = 60 # 帧时长(毫秒),匹配 Opus 编码 frame_duration = 60 # 帧时长(毫秒),匹配 Opus 编码
start_time = time.perf_counter() start_time = time.perf_counter()
play_position = 0 play_position = 0
last_reset_time = time.perf_counter() # 记录最后的重置时间
# 仅当第一句话时执行预缓冲 # 仅当第一句话时执行预缓冲
if pre_buffer: if pre_buffer:
+307 -39
View File
@@ -1,15 +1,20 @@
import os import os
import wave import wave
import copy
import uuid import uuid
import queue import queue
import asyncio import asyncio
import traceback import traceback
import threading import threading
import opuslib_next import opuslib_next
import json
import io
import aiohttp
import time
import concurrent.futures
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from config.logger import setup_logging from config.logger import setup_logging
from typing import Optional, Tuple, List from urllib.parse import urlparse, parse_qs
from typing import Optional, Tuple, List, Dict, Any
from core.handle.receiveAudioHandle import startToChat from core.handle.receiveAudioHandle import startToChat
from core.handle.reportHandle import enqueue_asr_report from core.handle.reportHandle import enqueue_asr_report
from core.utils.util import remove_punctuation_and_length from core.utils.util import remove_punctuation_and_length
@@ -18,16 +23,145 @@ from core.handle.receiveAudioHandle import handleAudioMessage
TAG = __name__ TAG = __name__
logger = setup_logging() logger = setup_logging()
# 创建全局线程池执行器用于CPU密集型操作
executor = concurrent.futures.ThreadPoolExecutor(max_workers=4)
class VoiceprintProvider:
"""声纹识别服务提供者"""
def __init__(self, config: dict):
self.original_url = config.get("url", "")
self.speakers = config.get("speakers", [])
self.speaker_map = self._parse_speakers()
# 解析API地址和密钥
self.api_url = None
self.api_key = None
self.speaker_ids = []
if not self.original_url:
logger.bind(tag=TAG).warning("声纹识别URL未配置,声纹识别将被禁用")
self.enabled = False
else:
# 解析URL和key
parsed_url = urlparse(self.original_url)
base_url = f"{parsed_url.scheme}://{parsed_url.netloc}"
# 从查询参数中提取key
query_params = parse_qs(parsed_url.query)
self.api_key = query_params.get('key', [''])[0]
if not self.api_key:
logger.bind(tag=TAG).error("URL中未找到key参数,声纹识别将被禁用")
self.enabled = False
else:
# 构造identify接口地址
self.api_url = f"{base_url}/voiceprint/identify"
# 提取speaker_ids
for speaker_str in self.speakers:
try:
parts = speaker_str.split(",", 2)
if len(parts) >= 1:
speaker_id = parts[0].strip()
self.speaker_ids.append(speaker_id)
except Exception:
continue
# 检查是否有有效的说话人配置
if not self.speaker_ids:
logger.bind(tag=TAG).warning("未配置有效的说话人,声纹识别将被禁用")
self.enabled = False
else:
self.enabled = True
logger.bind(tag=TAG).info(f"声纹识别已配置: API={self.api_url}, 说话人={len(self.speaker_ids)}")
def _parse_speakers(self) -> Dict[str, Dict[str, str]]:
"""解析说话人配置"""
speaker_map = {}
for speaker_str in self.speakers:
try:
parts = speaker_str.split(",", 2)
if len(parts) >= 3:
speaker_id, name, description = parts[0].strip(), parts[1].strip(), parts[2].strip()
speaker_map[speaker_id] = {
"name": name,
"description": description
}
except Exception as e:
logger.bind(tag=TAG).warning(f"解析说话人配置失败: {speaker_str}, 错误: {e}")
return speaker_map
async def identify_speaker(self, audio_data: bytes, session_id: str) -> Optional[str]:
"""识别说话人"""
if not self.enabled or not self.api_url or not self.api_key:
logger.bind(tag=TAG).debug("声纹识别功能已禁用或未配置,跳过识别")
return None
try:
api_start_time = time.monotonic()
# 准备请求头
headers = {
'Authorization': f'Bearer {self.api_key}',
'Accept': 'application/json'
}
# 准备multipart/form-data数据
data = aiohttp.FormData()
data.add_field('speaker_ids', ','.join(self.speaker_ids))
data.add_field('file', audio_data, filename='audio.wav', content_type='audio/wav')
timeout = aiohttp.ClientTimeout(total=10)
# 网络请求
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(self.api_url, headers=headers, data=data) as response:
if response.status == 200:
result = await response.json()
speaker_id = result.get("speaker_id")
score = result.get("score", 0)
total_elapsed_time = time.monotonic() - api_start_time
logger.bind(tag=TAG).info(f"声纹识别耗时: {total_elapsed_time:.3f}s")
# 置信度检查
if score < 0.5:
logger.bind(tag=TAG).warning(f"声纹识别置信度较低: {score:.3f}")
if speaker_id and speaker_id in self.speaker_map:
result_name = self.speaker_map[speaker_id]["name"]
return result_name
else:
logger.bind(tag=TAG).warning(f"未识别的说话人ID: {speaker_id}")
return "未知说话人"
else:
logger.bind(tag=TAG).error(f"声纹识别API错误: HTTP {response.status}")
return None
except asyncio.TimeoutError:
elapsed = time.monotonic() - api_start_time
logger.bind(tag=TAG).error(f"声纹识别超时: {elapsed:.3f}s")
return None
except Exception as e:
elapsed = time.monotonic() - api_start_time
logger.bind(tag=TAG).error(f"声纹识别失败: {e}")
return None
class ASRProviderBase(ABC): class ASRProviderBase(ABC):
def __init__(self): def __init__(self):
pass self.voiceprint_provider = None
def init_voiceprint(self, voiceprint_config: dict):
"""初始化声纹识别"""
if voiceprint_config:
self.voiceprint_provider = VoiceprintProvider(voiceprint_config)
logger.bind(tag=TAG).info("声纹识别模块已初始化")
# 打开音频通道 # 打开音频通道
# 这里默认是非流式的处理方式
# 流式处理方式请在子类中重写
async def open_audio_channels(self, conn): async def open_audio_channels(self, conn):
# tts 消化线程
conn.asr_priority_thread = threading.Thread( conn.asr_priority_thread = threading.Thread(
target=self.asr_text_priority_thread, args=(conn,), daemon=True target=self.asr_text_priority_thread, args=(conn,), daemon=True
) )
@@ -52,41 +186,173 @@ class ASRProviderBase(ABC):
continue continue
# 接收音频 # 接收音频
# 这里默认是非流式的处理方式
# 流式处理方式请在子类中重写
async def receive_audio(self, conn, audio, audio_have_voice): async def receive_audio(self, conn, audio, audio_have_voice):
if conn.client_listen_mode == "auto" or conn.client_listen_mode == "realtime": if conn.client_listen_mode == "auto" or conn.client_listen_mode == "realtime":
have_voice = audio_have_voice have_voice = audio_have_voice
else: else:
have_voice = conn.client_have_voice have_voice = conn.client_have_voice
# 如果本次没有声音,本段也没声音,就把声音丢弃了
conn.asr_audio.append(audio) conn.asr_audio.append(audio)
if have_voice == False and conn.client_have_voice == False: if not have_voice and not conn.client_have_voice:
conn.asr_audio = conn.asr_audio[-10:] conn.asr_audio = conn.asr_audio[-10:]
return return
# 如果本段有声音,且已经停止了
if conn.client_voice_stop: if conn.client_voice_stop:
asr_audio_task = copy.deepcopy(conn.asr_audio) asr_audio_task = conn.asr_audio.copy()
conn.asr_audio.clear() conn.asr_audio.clear()
# 音频太短了,无法识别
conn.reset_vad_states() conn.reset_vad_states()
if len(asr_audio_task) > 15: if len(asr_audio_task) > 15:
await self.handle_voice_stop(conn, asr_audio_task) await self.handle_voice_stop(conn, asr_audio_task)
# 处理语音停止 # 处理语音停止
async def handle_voice_stop(self, conn, asr_audio_task): async def handle_voice_stop(self, conn, asr_audio_task: List[bytes]):
raw_text, _ = await self.speech_to_text( """并行处理ASR和声纹识别"""
asr_audio_task, conn.session_id, conn.audio_format try:
) # 确保ASR模块返回原始文本 total_start_time = time.monotonic()
conn.logger.bind(tag=TAG).info(f"识别文本: {raw_text}")
text_len, _ = remove_punctuation_and_length(raw_text) # 准备音频数据
self.stop_ws_connection() if conn.audio_format == "pcm":
if text_len > 0: pcm_data = asr_audio_task
# 使用自定义模块进行上报 else:
await startToChat(conn, raw_text) pcm_data = self.decode_opus(asr_audio_task)
enqueue_asr_report(conn, raw_text, asr_audio_task)
combined_pcm_data = b"".join(pcm_data)
# 预先准备WAV数据
wav_data = None
if self.voiceprint_provider and combined_pcm_data:
wav_data = self._pcm_to_wav(combined_pcm_data)
# 定义ASR任务
def run_asr():
start_time = time.monotonic()
try:
import asyncio
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
result = loop.run_until_complete(
self.speech_to_text(asr_audio_task, conn.session_id, conn.audio_format)
)
end_time = time.monotonic()
logger.bind(tag=TAG).info(f"ASR耗时: {end_time - start_time:.3f}s")
return result
finally:
loop.close()
except Exception as e:
end_time = time.monotonic()
logger.bind(tag=TAG).error(f"ASR失败: {e}")
return ("", None)
# 定义声纹识别任务
def run_voiceprint():
if not wav_data:
return None
start_time = time.monotonic()
try:
import asyncio
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
result = loop.run_until_complete(
self.voiceprint_provider.identify_speaker(wav_data, conn.session_id)
)
return result
finally:
loop.close()
except Exception as e:
logger.bind(tag=TAG).error(f"声纹识别失败: {e}")
return None
# 使用线程池执行器并行运行
parallel_start_time = time.monotonic()
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as thread_executor:
asr_future = thread_executor.submit(run_asr)
if self.voiceprint_provider and wav_data:
voiceprint_future = thread_executor.submit(run_voiceprint)
# 等待两个线程都完成
asr_result = asr_future.result(timeout=15)
voiceprint_result = voiceprint_future.result(timeout=15)
results = {"asr": asr_result, "voiceprint": voiceprint_result}
else:
asr_result = asr_future.result(timeout=15)
results = {"asr": asr_result, "voiceprint": None}
parallel_execution_time = time.monotonic() - parallel_start_time
# 处理结果
raw_text, file_path = results.get("asr", ("", None))
speaker_name = results.get("voiceprint", None)
# 记录识别结果
if raw_text:
logger.bind(tag=TAG).info(f"识别文本: {raw_text}")
if speaker_name:
logger.bind(tag=TAG).info(f"识别说话人: {speaker_name}")
# 性能监控
total_time = time.monotonic() - total_start_time
logger.bind(tag=TAG).info(f"总处理耗时: {total_time:.3f}s")
# 检查文本长度
text_len, _ = remove_punctuation_and_length(raw_text)
self.stop_ws_connection()
if text_len > 0:
# 构建包含说话人信息的JSON字符串
enhanced_text = self._build_enhanced_text(raw_text, speaker_name)
# 使用自定义模块进行上报
await startToChat(conn, enhanced_text)
enqueue_asr_report(conn, enhanced_text, asr_audio_task)
except Exception as e:
logger.bind(tag=TAG).error(f"处理语音停止失败: {e}")
import traceback
logger.bind(tag=TAG).debug(f"异常详情: {traceback.format_exc()}")
def _build_enhanced_text(self, text: str, speaker_name: Optional[str]) -> str:
"""构建包含说话人信息的文本"""
if speaker_name:
return json.dumps({
"speaker": speaker_name,
"content": text
}, ensure_ascii=False)
else:
return text
def _pcm_to_wav(self, pcm_data: bytes) -> bytes:
"""将PCM数据转换为WAV格式"""
if len(pcm_data) == 0:
logger.bind(tag=TAG).warning("PCM数据为空,无法转换WAV")
return b""
# 确保数据长度是偶数(16位音频)
if len(pcm_data) % 2 != 0:
pcm_data = pcm_data[:-1]
# 创建WAV文件头
wav_buffer = io.BytesIO()
try:
with wave.open(wav_buffer, 'wb') as wav_file:
wav_file.setnchannels(1) # 单声道
wav_file.setsampwidth(2) # 16位
wav_file.setframerate(16000) # 16kHz采样率
wav_file.writeframes(pcm_data)
wav_buffer.seek(0)
wav_data = wav_buffer.read()
return wav_data
except Exception as e:
logger.bind(tag=TAG).error(f"WAV转换失败: {e}")
return b""
def stop_ws_connection(self): def stop_ws_connection(self):
pass pass
@@ -99,7 +365,7 @@ class ASRProviderBase(ABC):
with wave.open(file_path, "wb") as wf: with wave.open(file_path, "wb") as wf:
wf.setnchannels(1) wf.setnchannels(1)
wf.setsampwidth(2) # 2 bytes = 16-bit wf.setsampwidth(2)
wf.setframerate(16000) wf.setframerate(16000)
wf.writeframes(b"".join(pcm_data)) wf.writeframes(b"".join(pcm_data))
@@ -113,27 +379,29 @@ class ASRProviderBase(ABC):
pass pass
@staticmethod @staticmethod
def decode_opus(opus_data: List[bytes]) -> bytes: def decode_opus(opus_data: List[bytes]) -> List[bytes]:
"""将Opus音频数据解码为PCM数据""" """将Opus音频数据解码为PCM数据"""
try: try:
decoder = opuslib_next.Decoder(16000, 1) # 16kHz, 单声道 decoder = opuslib_next.Decoder(16000, 1)
pcm_data = [] pcm_data = []
buffer_size = 960 # 每次处理960个采样点 buffer_size = 960 # 每次处理960个采样点 (60ms at 16kHz)
for opus_packet in opus_data: for i, opus_packet in enumerate(opus_data):
try: try:
# 使用较小的缓冲区大小进行处理 if not opus_packet or len(opus_packet) == 0:
continue
pcm_frame = decoder.decode(opus_packet, buffer_size) pcm_frame = decoder.decode(opus_packet, buffer_size)
if pcm_frame: if pcm_frame and len(pcm_frame) > 0:
pcm_data.append(pcm_frame) pcm_data.append(pcm_frame)
except opuslib_next.OpusError as e: except opuslib_next.OpusError as e:
logger.bind(tag=TAG).warning(f"Opus解码错误,跳过当前数据包: {e}") logger.bind(tag=TAG).warning(f"Opus解码错误,跳过数据包 {i}: {e}")
continue
except Exception as e: except Exception as e:
logger.bind(tag=TAG).error(f"音频处理错误: {e}", exc_info=True) logger.bind(tag=TAG).error(f"音频处理错误,数据包 {i}: {e}")
continue
return pcm_data return pcm_data
except Exception as e: except Exception as e:
logger.bind(tag=TAG).error(f"音频解码过程发生错误: {e}", exc_info=True) logger.bind(tag=TAG).error(f"音频解码过程发生错误: {e}")
return [] return []
+7 -1
View File
@@ -74,8 +74,14 @@ class Dialogue:
) )
if system_message: if system_message:
# 构建增强的系统提示,包含说话人处理指导
speaker_guidance = "\n\n[说话人识别功能说明]\n" \
"当用户消息包含 [说话人: 姓名] 前缀时,表示系统已识别出说话人身份。\n" \
"请根据说话人的身份特征(如果之前有相关信息)来调整回应风格和内容。\n" \
"你可以称呼说话人的名字,并参考他们的特点进行个性化回应。"
enhanced_system_prompt = ( enhanced_system_prompt = (
f"{system_message.content}\n\n" f"{system_message.content}{speaker_guidance}\n\n"
f"以下是用户的历史记忆:\n```\n{memory_str}\n```" f"以下是用户的历史记忆:\n```\n{memory_str}\n```"
) )
dialogue.append({"role": "system", "content": enhanced_system_prompt}) dialogue.append({"role": "system", "content": enhanced_system_prompt})
@@ -125,4 +125,13 @@ def initialize_asr(config):
config["ASR"][select_asr_module], config["ASR"][select_asr_module],
str(config.get("delete_audio", True)).lower() in ("true", "1", "yes"), str(config.get("delete_audio", True)).lower() in ("true", "1", "yes"),
) )
# 初始化声纹识别功能
voiceprint_config = config.get("plugins", {}).get("voiceprint")
if voiceprint_config and voiceprint_config.get("url") and voiceprint_config.get("speakers"):
new_asr.init_voiceprint(voiceprint_config)
logger.bind(tag=TAG).info("ASR模块声纹识别功能已启用")
else:
logger.bind(tag=TAG).info("ASR模块声纹识别功能已禁用")
return new_asr return new_asr