resolve merge conflict

This commit is contained in:
caixypromise
2025-12-29 00:09:26 +08:00
375 changed files with 45358 additions and 9704 deletions
@@ -0,0 +1,160 @@
import time
import asyncio
from collections import deque
from config.logger import setup_logging
TAG = __name__
logger = setup_logging()
class AudioRateController:
"""
音频速率控制器 - 按照60ms帧时长精确控制音频发送
解决高并发下的时间累积误差问题
"""
def __init__(self, frame_duration=60):
"""
Args:
frame_duration: 单个音频帧时长(毫秒),默认60ms
"""
self.frame_duration = frame_duration
self.queue = deque()
self.play_position = 0 # 虚拟播放位置(毫秒)
self.start_timestamp = None # 开始时间戳(只读,不修改)
self.pending_send_task = None
self.logger = logger
self.queue_empty_event = asyncio.Event() # 队列清空事件
self.queue_empty_event.set() # 初始为空状态
self.queue_has_data_event = asyncio.Event() # 队列数据事件
def reset(self):
"""重置控制器状态"""
if self.pending_send_task and not self.pending_send_task.done():
self.pending_send_task.cancel()
# 取消任务后,任务会在下次事件循环时清理,无需阻塞等待
self.queue.clear()
self.play_position = 0
self.start_timestamp = None # 由首个音频包设置
# 相关事件处理
self.queue_empty_event.set()
self.queue_has_data_event.clear()
def add_audio(self, opus_packet):
"""添加音频包到队列"""
self.queue.append(("audio", opus_packet))
# 相关事件处理
self.queue_empty_event.clear()
self.queue_has_data_event.set()
def add_message(self, message_callback):
"""
添加消息到队列(立即发送,不占用播放时间)
Args:
message_callback: 消息发送回调函数 async def()
"""
self.queue.append(("message", message_callback))
# 相关事件处理
self.queue_empty_event.clear()
self.queue_has_data_event.set()
def _get_elapsed_ms(self):
"""获取已经过的时间(毫秒)"""
if self.start_timestamp is None:
return 0
return (time.monotonic() - self.start_timestamp) * 1000
async def check_queue(self, send_audio_callback):
"""
检查队列并按时发送音频/消息
Args:
send_audio_callback: 发送音频的回调函数 async def(opus_packet)
"""
while self.queue:
item = self.queue[0]
item_type = item[0]
if item_type == "message":
# 消息类型:立即发送,不占用播放时间
_, message_callback = item
self.queue.popleft()
try:
await message_callback()
except Exception as e:
self.logger.bind(tag=TAG).error(f"发送消息失败: {e}")
raise
elif item_type == "audio":
if self.start_timestamp is None:
self.start_timestamp = time.monotonic()
_, opus_packet = item
# 循环等待直到时间到达
while True:
# 计算时间差
elapsed_ms = self._get_elapsed_ms()
output_ms = self.play_position
if elapsed_ms < output_ms:
# 还不到发送时间,计算等待时长
wait_ms = output_ms - elapsed_ms
# 等待后继续检查(允许被中断)
try:
await asyncio.sleep(wait_ms / 1000)
except asyncio.CancelledError:
self.logger.bind(tag=TAG).debug("音频发送任务被取消")
raise
# 等待结束后重新检查时间(循环回到 while True)
else:
# 时间已到,跳出等待循环
break
# 时间已到,从队列移除并发送
self.queue.popleft()
self.play_position += self.frame_duration
try:
await send_audio_callback(opus_packet)
except Exception as e:
self.logger.bind(tag=TAG).error(f"发送音频失败: {e}")
raise
# 队列处理完后清除事件
self.queue_empty_event.set()
self.queue_has_data_event.clear()
def start_sending(self, send_audio_callback):
"""
启动异步发送任务
Args:
send_audio_callback: 发送音频的回调函数
Returns:
asyncio.Task: 发送任务
"""
async def _send_loop():
try:
while True:
# 等待队列数据事件,不轮询等待占用CPU
await self.queue_has_data_event.wait()
await self.check_queue(send_audio_callback)
except asyncio.CancelledError:
self.logger.bind(tag=TAG).debug("音频发送循环已停止")
except Exception as e:
self.logger.bind(tag=TAG).error(f"音频发送循环异常: {e}")
self.pending_send_task = asyncio.create_task(_send_loop())
return self.pending_send_task
def stop_sending(self):
"""停止发送任务"""
if self.pending_send_task and not self.pending_send_task.done():
self.pending_send_task.cancel()
self.logger.bind(tag=TAG).debug("已取消音频发送任务")
+4
View File
@@ -19,6 +19,7 @@ class CacheType(Enum):
CONFIG = "config"
DEVICE_PROMPT = "device_prompt"
VOICEPRINT_HEALTH = "voiceprint_health" # 声纹识别健康检查
AUDIO_DATA = "audio_data" # 音频数据缓存
@dataclass
@@ -58,5 +59,8 @@ class CacheConfig:
CacheType.VOICEPRINT_HEALTH: cls(
strategy=CacheStrategy.TTL, ttl=600, max_size=100 # 10分钟过期
),
CacheType.AUDIO_DATA: cls(
strategy=CacheStrategy.TTL, ttl=600, max_size=100 # 10分钟过期
),
}
return configs.get(cache_type, cls())
@@ -0,0 +1,64 @@
import httpx
from typing import Dict, Any, List
from config.logger import setup_logging
TAG = __name__
class ContextDataProvider:
"""数据上下文填充,负责从配置的API获取数据"""
def __init__(self, config: Dict[str, Any], logger=None):
self.config = config
self.logger = logger or setup_logging()
self.context_data = ""
def fetch_all(self, device_id: str) -> str:
"""获取所有配置的上下文数据"""
context_providers = self.config.get("context_providers", [])
if not context_providers:
return ""
formatted_lines = []
for provider in context_providers:
url = provider.get("url")
headers = provider.get("headers", {})
if not url:
continue
try:
headers = headers.copy() if isinstance(headers, dict) else {}
# 将 device_id 添加到请求头
headers["device-id"] = device_id
# 发送请求
response = httpx.get(url, headers=headers, timeout=3)
if response.status_code == 200:
result = response.json()
if isinstance(result, dict):
if result.get("code") == 0:
data = result.get("data")
# 格式化数据
if isinstance(data, dict):
for k, v in data.items():
formatted_lines.append(f"- **{k}** {v}")
elif isinstance(data, list):
for item in data:
formatted_lines.append(f"- {item}")
else:
formatted_lines.append(f"- {data}")
else:
self.logger.bind(tag=TAG).warning(f"API {url} 返回错误码: {result.get('msg')}")
else:
self.logger.bind(tag=TAG).warning(f"API {url} 返回的不是JSON字典")
else:
self.logger.bind(tag=TAG).warning(f"API {url} 请求失败: {response.status_code}")
except Exception as e:
self.logger.bind(tag=TAG).error(f"获取上下文数据 {url} 失败: {e}")
# 将所有格式化后的行拼接成一个字符串
self.context_data = "\n".join(formatted_lines)
if self.context_data:
self.logger.bind(tag=TAG).debug(f"已注入动态上下文数据:\n{self.context_data}")
return self.context_data
@@ -0,0 +1,68 @@
"""
时间工具模块
提供统一的时间获取功能
"""
import cnlunar
from datetime import datetime
WEEKDAY_MAP = {
"Monday": "星期一",
"Tuesday": "星期二",
"Wednesday": "星期三",
"Thursday": "星期四",
"Friday": "星期五",
"Saturday": "星期六",
"Sunday": "星期日",
}
def get_current_time() -> str:
"""
获取当前时间字符串 (格式: HH:MM)
"""
return datetime.now().strftime("%H:%M")
def get_current_date() -> str:
"""
获取今天日期字符串 (格式: YYYY-MM-DD)
"""
return datetime.now().strftime("%Y-%m-%d")
def get_current_weekday() -> str:
"""
获取今天星期几
"""
now = datetime.now()
return WEEKDAY_MAP[now.strftime("%A")]
def get_current_lunar_date() -> str:
"""
获取农历日期字符串
"""
try:
now = datetime.now()
today_lunar = cnlunar.Lunar(now, godType="8char")
return "%s%s%s" % (
today_lunar.lunarYearCn,
today_lunar.lunarMonthCn[:-1],
today_lunar.lunarDayCn,
)
except Exception:
return "农历获取失败"
def get_current_time_info() -> tuple:
"""
获取当前时间信息
返回: (当前时间字符串, 今天日期, 今天星期, 农历日期)
"""
current_time = get_current_time()
today_date = get_current_date()
today_weekday = get_current_weekday()
lunar_date = get_current_lunar_date()
return current_time, today_date, today_weekday, lunar_date
@@ -0,0 +1,122 @@
"""
全局GC管理模块
定期执行垃圾回收,避免频繁触发GC导致的GIL锁问题
"""
import gc
import asyncio
import threading
from config.logger import setup_logging
TAG = __name__
logger = setup_logging()
class GlobalGCManager:
"""全局垃圾回收管理器"""
def __init__(self, interval_seconds=300):
"""
初始化GC管理器
Args:
interval_seconds: GC执行间隔(秒),默认300秒(5分钟)
"""
self.interval_seconds = interval_seconds
self._task = None
self._stop_event = asyncio.Event()
self._lock = threading.Lock()
async def start(self):
"""启动定时GC任务"""
if self._task is not None:
logger.bind(tag=TAG).warning("GC管理器已经在运行")
return
logger.bind(tag=TAG).info(f"启动全局GC管理器,间隔{self.interval_seconds}")
self._stop_event.clear()
self._task = asyncio.create_task(self._gc_loop())
async def stop(self):
"""停止定时GC任务"""
if self._task is None:
return
logger.bind(tag=TAG).info("停止全局GC管理器")
self._stop_event.set()
if self._task and not self._task.done():
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
async def _gc_loop(self):
"""GC循环任务"""
try:
while not self._stop_event.is_set():
# 等待指定间隔
try:
await asyncio.wait_for(
self._stop_event.wait(), timeout=self.interval_seconds
)
# 如果stop_event被设置,退出循环
break
except asyncio.TimeoutError:
# 超时表示到了执行GC的时间
pass
# 执行GC
await self._run_gc()
except asyncio.CancelledError:
logger.bind(tag=TAG).info("GC循环任务被取消")
raise
except Exception as e:
logger.bind(tag=TAG).error(f"GC循环任务异常: {e}")
finally:
logger.bind(tag=TAG).info("GC循环任务已退出")
async def _run_gc(self):
"""执行垃圾回收"""
try:
# 在线程池中执行GC,避免阻塞事件循环
loop = asyncio.get_running_loop()
def do_gc():
with self._lock:
before = len(gc.get_objects())
collected = gc.collect()
after = len(gc.get_objects())
return before, collected, after
before, collected, after = await loop.run_in_executor(None, do_gc)
logger.bind(tag=TAG).debug(
f"全局GC执行完成 - 回收对象: {collected}, "
f"对象数量: {before} -> {after}"
)
except Exception as e:
logger.bind(tag=TAG).error(f"执行GC时出错: {e}")
# 全局单例
_gc_manager_instance = None
def get_gc_manager(interval_seconds=300):
"""
获取全局GC管理器实例(单例模式)
Args:
interval_seconds: GC执行间隔(秒),默认300秒(5分钟)
Returns:
GlobalGCManager实例
"""
global _gc_manager_instance
if _gc_manager_instance is None:
_gc_manager_instance = GlobalGCManager(interval_seconds)
return _gc_manager_instance
@@ -102,6 +102,9 @@ class OpusEncoderUtils:
def _encode(self, frame: np.ndarray) -> Optional[bytes]:
"""编码一帧音频数据"""
try:
# 编码器已释放,跳过编码
if not hasattr(self, 'encoder') or self.encoder is None:
return None
# 将numpy数组转换为bytes
frame_bytes = frame.tobytes()
# opuslib要求输入字节数必须是channels*2的倍数
@@ -128,5 +131,9 @@ class OpusEncoderUtils:
def close(self):
"""关闭编码器并释放资源"""
# opuslib没有明确的关闭方法,Python的垃圾回收会处理
pass
if hasattr(self, 'encoder') and self.encoder:
try:
del self.encoder
self.encoder = None
except Exception as e:
logging.error(f"Error releasing Opus encoder: {e}")
@@ -4,7 +4,6 @@
"""
import os
import cnlunar
from typing import Dict, Any
from config.logger import setup_logging
from jinja2 import Template
@@ -60,13 +59,20 @@ class PromptManager:
self.cache_manager = cache_manager
self.CacheType = CacheType
# 初始化上下文源
from core.utils.context_provider import ContextDataProvider
self.context_provider = ContextDataProvider(config, self.logger)
self.context_data = {}
self._load_base_template()
def _load_base_template(self):
"""加载基础提示词模板"""
try:
template_path = "agent-base-prompt.txt"
template_path = self.config.get("prompt_template", None)
if not template_path:
template_path = "agent-base-prompt.txt"
cache_key = f"prompt_template:{template_path}"
# 先从缓存获取
@@ -88,7 +94,7 @@ class PromptManager:
self.base_prompt_template = template_content
self.logger.bind(tag=TAG).debug("成功加载基础提示词模板并缓存")
else:
self.logger.bind(tag=TAG).warning("未找到agent-base-prompt.txt文件")
self.logger.bind(tag=TAG).warning(f"未找到{template_path}文件")
except Exception as e:
self.logger.bind(tag=TAG).error(f"加载提示词模板失败: {e}")
@@ -117,18 +123,16 @@ class PromptManager:
def _get_current_time_info(self) -> tuple:
"""获取当前时间信息"""
from datetime import datetime
now = datetime.now()
today_date = now.strftime("%Y-%m-%d")
today_weekday = WEEKDAY_MAP[now.strftime("%A")]
today_lunar = cnlunar.Lunar(now, godType="8char")
lunar_date = "%s%s%s\n" % (
today_lunar.lunarYearCn,
today_lunar.lunarMonthCn[:-1],
today_lunar.lunarDayCn,
from .current_time import (
get_current_date,
get_current_weekday,
get_current_lunar_date,
)
today_date = get_current_date()
today_weekday = get_current_weekday()
lunar_date = get_current_lunar_date() + "\n"
return today_date, today_weekday, lunar_date
def _get_location_info(self, client_ip: str) -> str:
@@ -180,17 +184,40 @@ class PromptManager:
def update_context_info(self, conn, client_ip: str):
"""同步更新上下文信息"""
try:
# 获取位置信息(使用全局缓存)
local_address = self._get_location_info(client_ip)
# 获取天气信息(使用全局缓存)
self._get_weather_info(conn, local_address)
self.logger.bind(tag=TAG).info(f"上下文信息更新完成")
local_address = ""
if (
client_ip
and self.base_prompt_template
and (
"local_address" in self.base_prompt_template
or "weather_info" in self.base_prompt_template
)
):
# 获取位置信息(使用全局缓存)
local_address = self._get_location_info(client_ip)
if (
self.base_prompt_template
and "weather_info" in self.base_prompt_template
and local_address
):
# 获取天气信息(使用全局缓存)
self._get_weather_info(conn, local_address)
# 获取配置的上下文数据
if hasattr(conn, "device_id") and conn.device_id:
if self.base_prompt_template and "dynamic_context" in self.base_prompt_template:
self.context_data = self.context_provider.fetch_all(conn.device_id)
else:
self.context_data = ""
self.logger.bind(tag=TAG).debug(f"上下文信息更新完成")
except Exception as e:
self.logger.bind(tag=TAG).error(f"更新上下文信息失败: {e}")
def build_enhanced_prompt(
self, user_prompt: str, device_id: str, client_ip: str = None
self, user_prompt: str, device_id: str, client_ip: str = None, *args, **kwargs
) -> str:
"""构建增强的系统提示词"""
if not self.base_prompt_template:
@@ -198,9 +225,7 @@ class PromptManager:
try:
# 获取最新的时间信息(不缓存)
today_date, today_weekday, lunar_date = (
self._get_current_time_info()
)
today_date, today_weekday, lunar_date = self._get_current_time_info()
# 获取缓存的上下文信息
local_address = ""
@@ -230,6 +255,11 @@ class PromptManager:
local_address=local_address,
weather_info=weather_info,
emojiList=EMOJI_List,
device_id=device_id,
client_ip=client_ip,
dynamic_context=self.context_data,
*args,
**kwargs,
)
device_cache_key = f"device_prompt:{device_id}"
self.cache_manager.set(
+26
View File
@@ -6,6 +6,27 @@ import importlib
logger = setup_logging()
punctuation_set = {
"",
",", # 中文逗号 + 英文逗号
"",
".", # 中文句号 + 英文句号
"",
"!", # 中文感叹号 + 英文感叹号
"",
"",
'"', # 中文双引号 + 英文引号
"",
":", # 中文冒号 + 英文冒号
"-",
"", # 英文连字符 + 中文全角横线
"", # 中文顿号
"[",
"]", # 方括号
"",
"", # 中文方括号
"~", # 波浪号
}
def create_instance(class_name, *args, **kwargs):
# 创建TTS实例
@@ -107,6 +128,11 @@ class MarkdownCleaner:
"""
主入口方法:依序执行所有正则,移除或替换 Markdown 元素
"""
# 检查文本是否全为英文和基本标点符号
if text and all((c.isascii() or c.isspace() or c in punctuation_set) for c in text):
# 保留原始空格,直接返回
return text
for regex, replacement in MarkdownCleaner.REGEXES:
text = regex.sub(replacement, text)
return text.strip()
+160 -71
View File
@@ -4,6 +4,7 @@ import json
import copy
import wave
import socket
import asyncio
import requests
import subprocess
import numpy as np
@@ -176,31 +177,67 @@ def parse_string_to_list(value, separator=";"):
return []
def check_ffmpeg_installed():
ffmpeg_installed = False
def check_ffmpeg_installed() -> bool:
"""
检查当前环境中是否已正确安装并可执行 ffmpeg。
Returns:
bool: 如果 ffmpeg 正常可用,返回 True;否则抛出 ValueError 异常。
Raises:
ValueError: 当检测到 ffmpeg 未安装或依赖缺失时,抛出详细的提示信息。
"""
try:
# 执行ffmpeg -version命令,并捕获输出
# 尝试执行 ffmpeg 命令
result = subprocess.run(
["ffmpeg", "-version"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
check=True, # 如果返回码非零则抛出异常
check=True, # 非零退出码会触发 CalledProcessError
)
# 检查输出中是否包含版本信息(可选)
output = result.stdout + result.stderr
if "ffmpeg version" in output.lower():
ffmpeg_installed = True
return False
except (subprocess.CalledProcessError, FileNotFoundError):
# 命令执行失败或未找到
ffmpeg_installed = False
if not ffmpeg_installed:
error_msg = "您的电脑还没正确安装ffmpeg\n"
error_msg += "\n建议您:\n"
error_msg += "1、按照项目的安装文档,正确进入conda环境\n"
error_msg += "2、查阅安装文档,如何在conda环境中安装ffmpeg\n"
raise ValueError(error_msg)
output = (result.stdout + result.stderr).lower()
if "ffmpeg version" in output:
return True
# 如果未检测到版本信息,也视为异常情况
raise ValueError("未检测到有效的 ffmpeg 版本输出。")
except (subprocess.CalledProcessError, FileNotFoundError) as e:
# 提取错误输出
stderr_output = ""
if isinstance(e, subprocess.CalledProcessError):
stderr_output = (e.stderr or "").strip()
else:
stderr_output = str(e).strip()
# 构建基础错误提示
error_msg = [
"❌ 检测到 ffmpeg 无法正常运行。\n",
"建议您:",
"1. 确认已正确激活 conda 环境;",
"2. 查阅项目安装文档,了解如何在 conda 环境中安装 ffmpeg。\n",
]
# 🎯 针对具体错误信息提供额外提示
if "libiconv.so.2" in stderr_output:
error_msg.append("⚠️ 发现缺少依赖库:libiconv.so.2")
error_msg.append("解决方法:在当前 conda 环境中执行:")
error_msg.append(" conda install -c conda-forge libiconv\n")
elif (
"no such file or directory" in stderr_output
and "ffmpeg" in stderr_output.lower()
):
error_msg.append("⚠️ 系统未找到 ffmpeg 可执行文件。")
error_msg.append("解决方法:在当前 conda 环境中执行:")
error_msg.append(" conda install -c conda-forge ffmpeg\n")
else:
error_msg.append("错误详情:")
error_msg.append(stderr_output or "未知错误。")
# 抛出详细异常信息
raise ValueError("\n".join(error_msg)) from e
def extract_json_from_string(input_string):
@@ -212,7 +249,9 @@ def extract_json_from_string(input_string):
return None
def audio_to_data_stream(audio_file_path, is_opus=True, callback: Callable[[Any], Any]=None) -> None:
def audio_to_data_stream(
audio_file_path, is_opus=True, callback: Callable[[Any], Any] = None
) -> None:
# 获取文件后缀名
file_type = os.path.splitext(audio_file_path)[1]
if file_type:
@@ -229,58 +268,88 @@ def audio_to_data_stream(audio_file_path, is_opus=True, callback: Callable[[Any]
raw_data = audio.raw_data
pcm_to_data_stream(raw_data, is_opus, callback)
def audio_to_data(audio_file_path: str, is_opus: bool = True) -> list[bytes]:
async def audio_to_data(
audio_file_path: str, is_opus: bool = True, use_cache: bool = True
) -> list[bytes]:
"""
将音频文件转换为Opus/PCM编码的帧列表
Args:
audio_file_path: 音频文件路径
is_opus: 是否进行Opus编码
use_cache: 是否使用缓存
"""
# 获取文件后缀名
file_type = os.path.splitext(audio_file_path)[1]
if file_type:
file_type = file_type.lstrip(".")
# 读取音频文件,-nostdin 参数:不要从标准输入读取数据,否则FFmpeg会阻塞
audio = AudioSegment.from_file(
audio_file_path, format=file_type, parameters=["-nostdin"]
)
from core.utils.cache.manager import cache_manager
from core.utils.cache.config import CacheType
# 转换为单声道/16kHz采样率/16位小端编码(确保与编码器匹配)
audio = audio.set_channels(1).set_frame_rate(16000).set_sample_width(2)
# 生成缓存键,包含文件路径和编码类型
cache_key = f"{audio_file_path}:{is_opus}"
# 获取原始PCM数据(16位小端)
raw_data = audio.raw_data
# 尝试从缓存获取结果
if use_cache:
cached_result = cache_manager.get(CacheType.AUDIO_DATA, cache_key)
if cached_result is not None:
return cached_result
# 初始化Opus编码器
encoder = opuslib_next.Encoder(16000, 1, opuslib_next.APPLICATION_AUDIO)
def _sync_audio_to_data():
# 获取文件后缀名
file_type = os.path.splitext(audio_file_path)[1]
if file_type:
file_type = file_type.lstrip(".")
# 读取音频文件,-nostdin 参数:不要从标准输入读取数据,否则FFmpeg会阻塞
audio = AudioSegment.from_file(
audio_file_path, format=file_type, parameters=["-nostdin"]
)
# 编码参数
frame_duration = 60 # 60ms per frame
frame_size = int(16000 * frame_duration / 1000) # 960 samples/frame
# 转换为单声道/16kHz采样率/16位小端编码(确保与编码器匹配)
audio = audio.set_channels(1).set_frame_rate(16000).set_sample_width(2)
datas = []
# 按帧处理所有音频数据(包括最后一帧可能补零)
for i in range(0, len(raw_data), frame_size * 2): # 16bit=2bytes/sample
# 获取当前帧的二进制数据
chunk = raw_data[i : i + frame_size * 2]
# 获取原始PCM数据(16位小端)
raw_data = audio.raw_data
# 如果最后一帧不足,补零
if len(chunk) < frame_size * 2:
chunk += b"\x00" * (frame_size * 2 - len(chunk))
# 初始化Opus编码器
encoder = opuslib_next.Encoder(16000, 1, opuslib_next.APPLICATION_AUDIO)
if is_opus:
# 转换为numpy数组处理
np_frame = np.frombuffer(chunk, dtype=np.int16)
# 编码Opus数据
frame_data = encoder.encode(np_frame.tobytes(), frame_size)
else:
frame_data = chunk if isinstance(chunk, bytes) else bytes(chunk)
# 编码参数
frame_duration = 60 # 60ms per frame
frame_size = int(16000 * frame_duration / 1000) # 960 samples/frame
datas.append(frame_data)
datas = []
# 按帧处理所有音频数据(包括最后一帧可能补零)
for i in range(0, len(raw_data), frame_size * 2): # 16bit=2bytes/sample
# 获取当前帧的二进制数据
chunk = raw_data[i : i + frame_size * 2]
return datas
# 如果最后一帧不足,补零
if len(chunk) < frame_size * 2:
chunk += b"\x00" * (frame_size * 2 - len(chunk))
def audio_bytes_to_data_stream(audio_bytes, file_type, is_opus, callback: Callable[[Any], Any]) -> None:
if is_opus:
# 转换为numpy数组处理
np_frame = np.frombuffer(chunk, dtype=np.int16)
# 编码Opus数据
frame_data = encoder.encode(np_frame.tobytes(), frame_size)
else:
frame_data = chunk if isinstance(chunk, bytes) else bytes(chunk)
datas.append(frame_data)
return datas
loop = asyncio.get_running_loop()
# 在单独的线程中执行同步的音频处理操作
result = await loop.run_in_executor(None, _sync_audio_to_data)
# 将结果存入缓存,使用配置中定义的TTL(10分钟)
if use_cache:
cache_manager.set(CacheType.AUDIO_DATA, cache_key, result)
return result
def audio_bytes_to_data_stream(
audio_bytes, file_type, is_opus, callback: Callable[[Any], Any]
) -> None:
"""
直接用音频二进制数据转为opus/pcm数据,支持wav、mp3、p3
"""
@@ -324,31 +393,40 @@ def pcm_to_data_stream(raw_data, is_opus=True, callback: Callable[[Any], Any] =
frame_data = chunk if isinstance(chunk, bytes) else bytes(chunk)
callback(frame_data)
def opus_datas_to_wav_bytes(opus_datas, sample_rate=16000, channels=1):
"""
将opus帧列表解码为wav字节流
"""
decoder = opuslib_next.Decoder(sample_rate, channels)
pcm_datas = []
try:
pcm_datas = []
frame_duration = 60 # ms
frame_size = int(sample_rate * frame_duration / 1000) # 960
frame_duration = 60 # ms
frame_size = int(sample_rate * frame_duration / 1000) # 960
for opus_frame in opus_datas:
# 解码为PCM(返回bytes,2字节/采样点)
pcm = decoder.decode(opus_frame, frame_size)
pcm_datas.append(pcm)
for opus_frame in opus_datas:
# 解码为PCM(返回bytes,2字节/采样点)
pcm = decoder.decode(opus_frame, frame_size)
pcm_datas.append(pcm)
pcm_bytes = b"".join(pcm_datas)
pcm_bytes = b"".join(pcm_datas)
# 写入wav字节流
wav_buffer = BytesIO()
with wave.open(wav_buffer, "wb") as wf:
wf.setnchannels(channels)
wf.setsampwidth(2) # 16bit
wf.setframerate(sample_rate)
wf.writeframes(pcm_bytes)
return wav_buffer.getvalue()
finally:
if decoder is not None:
try:
del decoder
except Exception:
pass
# 写入wav字节流
wav_buffer = BytesIO()
with wave.open(wav_buffer, "wb") as wf:
wf.setnchannels(channels)
wf.setsampwidth(2) # 16bit
wf.setframerate(sample_rate)
wf.writeframes(pcm_bytes)
return wav_buffer.getvalue()
def check_vad_update(before_config, new_config):
if (
@@ -423,6 +501,17 @@ def filter_sensitive_info(config: dict) -> dict:
filtered[k] = _filter_dict(v)
elif isinstance(v, list):
filtered[k] = [_filter_dict(i) if isinstance(i, dict) else i for i in v]
elif isinstance(v, str):
try:
json_data = json.loads(v)
if isinstance(json_data, dict):
filtered[k] = json.dumps(
_filter_dict(json_data), ensure_ascii=False
)
else:
filtered[k] = v
except (json.JSONDecodeError, TypeError):
filtered[k] = v
else:
filtered[k] = v
return filtered
@@ -19,6 +19,8 @@ class VoiceprintProvider:
self.original_url = config.get("url", "")
self.speakers = config.get("speakers", [])
self.speaker_map = self._parse_speakers()
# 声纹识别相似度阈值,默认0.4
self.similarity_threshold = float(config.get("similarity_threshold", 0.4))
# 解析API地址和密钥
self.api_url = None
@@ -62,7 +64,7 @@ class VoiceprintProvider:
# 进行健康检查,验证服务器是否可用
if self._check_server_health():
self.enabled = True
logger.bind(tag=TAG).info(f"声纹识别已启用: API={self.api_url}, 说话人={len(self.speaker_ids)}")
logger.bind(tag=TAG).info(f"声纹识别已启用: API={self.api_url}, 说话人={len(self.speaker_ids)}, 相似度阈值={self.similarity_threshold}")
else:
self.enabled = False
logger.bind(tag=TAG).warning(f"声纹识别服务器不可用,声纹识别已禁用: {self.api_url}")
@@ -169,12 +171,14 @@ class VoiceprintProvider:
logger.bind(tag=TAG).info(f"声纹识别耗时: {total_elapsed_time:.3f}s")
# 置信度检查
if score < 0.5:
logger.bind(tag=TAG).warning(f"声纹识别置信度较低: {score:.3f}")
# 相似度阈值检查
if score < self.similarity_threshold:
logger.bind(tag=TAG).warning(f"声纹识别相似度{score:.3f}低于阈值{self.similarity_threshold}")
return "未知说话人"
if speaker_id and speaker_id in self.speaker_map:
result_name = self.speaker_map[speaker_id]["name"]
logger.bind(tag=TAG).info(f"声纹识别成功: {result_name} (相似度: {score:.3f})")
return result_name
else:
logger.bind(tag=TAG).warning(f"未识别的说话人ID: {speaker_id}")
@@ -55,7 +55,7 @@ class WakeupWordsConfig:
return self._config_cache
try:
with open(self.config_file, "a+") as f:
with open(self.config_file, "a+", encoding="utf-8") as f:
with FileLock(f, timeout=self._lock_timeout):
f.seek(0)
content = f.read()
@@ -73,7 +73,7 @@ class WakeupWordsConfig:
def _save_config(self, config: Dict):
"""保存配置到文件,使用文件锁保护"""
try:
with open(self.config_file, "w") as f:
with open(self.config_file, "w", encoding="utf-8") as f:
with FileLock(f, timeout=self._lock_timeout):
yaml.dump(config, f, allow_unicode=True)
self._config_cache = config