Files
xiaozhi-esp32-server/main/xiaozhi-server/core/utils/util.py
T

525 lines
16 KiB
Python
Raw Normal View History

2025-02-02 23:01:14 +08:00
import json
2025-02-14 00:54:59 +08:00
import socket
2025-03-01 17:09:01 +08:00
import subprocess
2025-03-11 00:25:33 +08:00
import re
2025-05-21 13:18:12 +08:00
import os
import wave
from io import BytesIO
2025-08-05 14:53:52 +08:00
from typing import Callable, Any
from core.utils import p3
2025-05-21 13:18:12 +08:00
import numpy as np
2025-03-17 14:20:40 +08:00
import requests
2025-05-21 13:18:12 +08:00
import opuslib_next
from pydub import AudioSegment
import copy
2025-02-02 23:01:14 +08:00
2025-05-21 13:18:12 +08:00
TAG = __name__
emoji_map = {
"neutral": "😶",
"happy": "🙂",
"laughing": "😆",
"funny": "😂",
"sad": "😔",
"angry": "😠",
"crying": "😭",
"loving": "😍",
"embarrassed": "😳",
"surprised": "😲",
"shocked": "😱",
"thinking": "🤔",
"winking": "😉",
"cool": "😎",
"relaxed": "😌",
"delicious": "🤤",
"kissy": "😘",
"confident": "😏",
"sleepy": "😴",
"silly": "😜",
"confused": "🙄",
}
2025-02-02 23:01:14 +08:00
def get_local_ip():
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
# Connect to Google's DNS servers
s.connect(("8.8.8.8", 80))
local_ip = s.getsockname()[0]
s.close()
return local_ip
except Exception as e:
return "127.0.0.1"
2025-04-05 17:16:06 +08:00
2025-03-17 14:20:40 +08:00
def is_private_ip(ip_addr):
"""
Check if an IP address is a private IP address (compatible with IPv4 and IPv6).
@param {string} ip_addr - The IP address to check.
@return {bool} True if the IP address is private, False otherwise.
"""
try:
# Validate IPv4 or IPv6 address format
2025-04-05 17:16:06 +08:00
if not re.match(
r"^(\d{1,3}\.){3}\d{1,3}$|^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$", ip_addr
):
2025-03-17 14:20:40 +08:00
return False # Invalid IP address format
# IPv4 private address ranges
2025-04-05 17:16:06 +08:00
if "." in ip_addr: # IPv4 address
ip_parts = list(map(int, ip_addr.split(".")))
2025-03-17 14:20:40 +08:00
if ip_parts[0] == 10:
return True # 10.0.0.0/8 range
elif ip_parts[0] == 172 and 16 <= ip_parts[1] <= 31:
return True # 172.16.0.0/12 range
elif ip_parts[0] == 192 and ip_parts[1] == 168:
return True # 192.168.0.0/16 range
2025-04-05 17:16:06 +08:00
elif ip_addr == "127.0.0.1":
2025-03-17 14:20:40 +08:00
return True # Loopback address
elif ip_parts[0] == 169 and ip_parts[1] == 254:
2025-04-05 17:16:06 +08:00
return True # Link-local address 169.254.0.0/16
2025-03-17 14:20:40 +08:00
else:
return False # Not a private IPv4 address
else: # IPv6 address
ip_addr = ip_addr.lower()
2025-04-05 17:16:06 +08:00
if ip_addr.startswith("fc00:") or ip_addr.startswith("fd00:"):
2025-03-17 14:20:40 +08:00
return True # Unique Local Addresses (FC00::/7)
2025-04-05 17:16:06 +08:00
elif ip_addr == "::1":
2025-03-17 14:20:40 +08:00
return True # Loopback address
2025-04-05 17:16:06 +08:00
elif ip_addr.startswith("fe80:"):
return True # Link-local unicast addresses (FE80::/10)
2025-03-17 14:20:40 +08:00
else:
return False # Not a private IPv6 address
except (ValueError, IndexError):
return False # IP address format error or insufficient segments
2025-04-05 17:16:06 +08:00
2025-05-21 13:18:12 +08:00
def get_ip_info(ip_addr, logger):
2025-03-17 14:20:40 +08:00
try:
2025-07-08 16:59:38 +08:00
# 导入全局缓存管理器
from core.utils.cache.manager import cache_manager, CacheType
# 先从缓存获取
cached_ip_info = cache_manager.get(CacheType.IP_INFO, ip_addr)
if cached_ip_info is not None:
return cached_ip_info
# 缓存未命中,调用API
2025-04-05 17:16:06 +08:00
if is_private_ip(ip_addr):
ip_addr = ""
2025-04-01 17:27:58 +08:00
url = f"https://whois.pconline.com.cn/ipJson.jsp?json=true&ip={ip_addr}"
2025-03-17 14:20:40 +08:00
resp = requests.get(url).json()
2025-04-05 17:16:06 +08:00
ip_info = {"city": resp.get("city")}
2025-07-08 16:59:38 +08:00
# 存入缓存
cache_manager.set(CacheType.IP_INFO, ip_addr, ip_info)
2025-03-17 14:20:40 +08:00
return ip_info
except Exception as e:
2025-05-21 13:18:12 +08:00
logger.bind(tag=TAG).error(f"Error getting client ip info: {e}")
2025-03-17 14:20:40 +08:00
return {}
2025-02-02 23:01:14 +08:00
def write_json_file(file_path, data):
"""将数据写入 JSON 文件"""
2025-04-05 17:16:06 +08:00
with open(file_path, "w", encoding="utf-8") as file:
2025-02-02 23:01:14 +08:00
json.dump(data, file, ensure_ascii=False, indent=4)
def remove_punctuation_and_length(text):
# 全角符号和半角符号的Unicode范围
2025-04-05 17:16:06 +08:00
full_width_punctuations = (
"!"#$%&'()*+,-。/:;<=>?@[\]^_`{|}~"
)
2025-03-09 01:02:37 +08:00
half_width_punctuations = r'!"#$%&\'()*+,-./:;<=>?@[\]^_`{|}~'
2025-04-05 17:16:06 +08:00
space = " " # 半角空格
full_width_space = " " # 全角空格
2025-02-02 23:01:14 +08:00
# 去除全角和半角符号以及空格
2025-04-05 17:16:06 +08:00
result = "".join(
[
char
for char in text
if char not in full_width_punctuations
and char not in half_width_punctuations
and char not in space
and char not in full_width_space
]
)
2025-02-02 23:01:14 +08:00
if result == "Yeah":
return 0, ""
2025-02-14 23:09:12 +08:00
return len(result), result
2025-02-15 16:17:08 +08:00
2025-04-05 17:16:06 +08:00
+2
2025-03-07 18:25:18 +08:00
def check_model_key(modelType, modelKey):
if "你" in modelKey:
2025-06-12 23:11:42 +08:00
return f"配置错误: {modelType} 的 API key 未设置,当前值为: {modelKey}"
return None
2025-03-01 17:09:01 +08:00
+2
2025-03-07 18:25:18 +08:00
2025-05-21 13:18:12 +08:00
def parse_string_to_list(value, separator=";"):
"""
将输入值转换为列表
Args:
value: 输入值,可以是 None、字符串或列表
separator: 分隔符,默认为分号
Returns:
list: 处理后的列表
"""
if value is None or value == "":
return []
elif isinstance(value, str):
return [item.strip() for item in value.split(separator) if item.strip()]
elif isinstance(value, list):
return value
return []
2025-03-01 17:09:01 +08:00
def check_ffmpeg_installed():
ffmpeg_installed = False
try:
# 执行ffmpeg -version命令,并捕获输出
result = subprocess.run(
2025-04-05 17:16:06 +08:00
["ffmpeg", "-version"],
2025-03-01 17:09:01 +08:00
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
2025-04-05 17:16:06 +08:00
check=True, # 如果返回码非零则抛出异常
2025-03-01 17:09:01 +08:00
)
# 检查输出中是否包含版本信息(可选)
output = result.stdout + result.stderr
2025-04-05 17:16:06 +08:00
if "ffmpeg version" in output.lower():
2025-03-01 17:09:01 +08:00
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"
+2
2025-03-07 18:25:18 +08:00
raise ValueError(error_msg)
2025-04-05 17:16:06 +08:00
2025-03-11 00:25:33 +08:00
def extract_json_from_string(input_string):
"""提取字符串中的 JSON 部分"""
2025-04-05 17:16:06 +08:00
pattern = r"(\{.*\})"
2025-05-21 13:18:12 +08:00
match = re.search(pattern, input_string, re.DOTALL) # 添加 re.DOTALL
2025-03-11 00:25:33 +08:00
if match:
return match.group(1) # 返回提取的 JSON 字符串
2025-04-01 17:27:58 +08:00
return None
2025-05-21 13:18:12 +08:00
def audio_to_data(audio_file_path, is_opus=True):
# 获取文件后缀名
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"]
)
# 转换为单声道/16kHz采样率/16位小端编码(确保与编码器匹配)
audio = audio.set_channels(1).set_frame_rate(16000).set_sample_width(2)
# 获取原始PCM数据(16位小端)
raw_data = audio.raw_data
2025-08-04 13:49:12 +08:00
return pcm_to_data(raw_data, is_opus)
2025-05-21 13:18:12 +08:00
2025-08-05 18:35:37 +08:00
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:
file_type = file_type.lstrip(".")
# 读取音频文件,-nostdin 参数:不要从标准输入读取数据,否则FFmpeg会阻塞
audio = AudioSegment.from_file(
audio_file_path, format=file_type, parameters=["-nostdin"]
)
# 转换为单声道/16kHz采样率/16位小端编码(确保与编码器匹配)
audio = audio.set_channels(1).set_frame_rate(16000).set_sample_width(2)
# 获取原始PCM数据(16位小端)
raw_data = audio.raw_data
pcm_to_data_stream(raw_data, is_opus,callback)
2025-05-24 23:43:16 +08:00
def audio_bytes_to_data(audio_bytes, file_type, is_opus=True):
"""
直接用音频二进制数据转为opus/pcm数据,支持wav、mp3、p3
"""
if file_type == "p3":
# 直接用p3解码
return p3.decode_opus_from_bytes(audio_bytes)
else:
# 其他格式用pydub
2025-06-12 23:11:42 +08:00
audio = AudioSegment.from_file(
BytesIO(audio_bytes), format=file_type, parameters=["-nostdin"]
)
audio = audio.set_channels(1).set_frame_rate(16000).set_sample_width(2)
raw_data = audio.raw_data
2025-08-04 13:49:12 +08:00
return pcm_to_data(raw_data, is_opus)
2025-08-05 18:35:37 +08:00
def audio_bytes_to_data_stream(audio_bytes, file_type, is_opus, callback: Callable[[Any], Any]) -> None:
2025-08-05 14:53:52 +08:00
"""
直接用音频二进制数据转为opus/pcm数据,支持wav、mp3、p3
"""
if file_type == "p3":
# 直接用p3解码
return p3.decode_opus_from_bytes_stream(audio_bytes, callback)
else:
# 其他格式用pydub
audio = AudioSegment.from_file(
BytesIO(audio_bytes), format=file_type, parameters=["-nostdin"]
)
audio = audio.set_channels(1).set_frame_rate(16000).set_sample_width(2)
raw_data = audio.raw_data
pcm_to_data_stream(raw_data, is_opus, callback)
2025-05-24 23:43:16 +08:00
def pcm_to_data(raw_data, is_opus=True):
2025-05-21 13:18:12 +08:00
# 初始化Opus编码器
encoder = opuslib_next.Encoder(16000, 1, opuslib_next.APPLICATION_AUDIO)
# 编码参数
frame_duration = 60 # 60ms per frame
frame_size = int(16000 * frame_duration / 1000) # 960 samples/frame
datas = []
# 按帧处理所有音频数据(包括最后一帧可能补零)
for i in range(0, len(raw_data), frame_size * 2): # 16bit=2bytes/sample
# 获取当前帧的二进制数据
chunk = raw_data[i : i + frame_size * 2]
# 如果最后一帧不足,补零
if len(chunk) < frame_size * 2:
chunk += b"\x00" * (frame_size * 2 - len(chunk))
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)
2025-05-24 23:43:16 +08:00
return datas
2025-05-21 13:18:12 +08:00
2025-08-05 14:53:52 +08:00
def pcm_to_data_stream(raw_data, is_opus=True, callback: Callable[[Any], Any]=None):
# 初始化Opus编码器
encoder = opuslib_next.Encoder(16000, 1, opuslib_next.APPLICATION_AUDIO)
# 编码参数
frame_duration = 60 # 60ms per frame
frame_size = int(16000 * frame_duration / 1000) # 960 samples/frame
# 按帧处理所有音频数据(包括最后一帧可能补零)
for i in range(0, len(raw_data), frame_size * 2): # 16bit=2bytes/sample
# 获取当前帧的二进制数据
chunk = raw_data[i : i + frame_size * 2]
# 如果最后一帧不足,补零
if len(chunk) < frame_size * 2:
chunk += b"\x00" * (frame_size * 2 - len(chunk))
if is_opus:
# 转换为numpy数组处理
np_frame = np.frombuffer(chunk, dtype=np.int16)
# 编码Opus数据
frame_data = encoder.encode(np_frame.tobytes(), frame_size)
callback(frame_data)
else:
frame_data = chunk if isinstance(chunk, bytes) else bytes(chunk)
callback(frame_data)
2025-05-21 13:18:12 +08:00
def opus_datas_to_wav_bytes(opus_datas, sample_rate=16000, channels=1):
"""
将opus帧列表解码为wav字节流
"""
decoder = opuslib_next.Decoder(sample_rate, channels)
pcm_datas = []
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)
2025-06-12 23:11:42 +08:00
pcm_bytes = b"".join(pcm_datas)
# 写入wav字节流
wav_buffer = BytesIO()
2025-06-12 23:11:42 +08:00
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()
2025-05-21 13:18:12 +08:00
def check_vad_update(before_config, new_config):
if (
new_config.get("selected_module") is None
or new_config["selected_module"].get("VAD") is None
):
return False
update_vad = False
current_vad_module = before_config["selected_module"]["VAD"]
new_vad_module = new_config["selected_module"]["VAD"]
current_vad_type = (
current_vad_module
if "type" not in before_config["VAD"][current_vad_module]
else before_config["VAD"][current_vad_module]["type"]
)
new_vad_type = (
new_vad_module
if "type" not in new_config["VAD"][new_vad_module]
else new_config["VAD"][new_vad_module]["type"]
)
update_vad = current_vad_type != new_vad_type
return update_vad
def check_asr_update(before_config, new_config):
if (
new_config.get("selected_module") is None
or new_config["selected_module"].get("ASR") is None
):
return False
update_asr = False
current_asr_module = before_config["selected_module"]["ASR"]
new_asr_module = new_config["selected_module"]["ASR"]
current_asr_type = (
current_asr_module
if "type" not in before_config["ASR"][current_asr_module]
else before_config["ASR"][current_asr_module]["type"]
)
new_asr_type = (
new_asr_module
if "type" not in new_config["ASR"][new_asr_module]
else new_config["ASR"][new_asr_module]["type"]
)
update_asr = current_asr_type != new_asr_type
return update_asr
def filter_sensitive_info(config: dict) -> dict:
"""
过滤配置中的敏感信息
Args:
config: 原始配置字典
Returns:
过滤后的配置字典
"""
sensitive_keys = [
"api_key",
"personal_access_token",
"access_token",
"token",
"secret",
"access_key_secret",
"secret_key",
]
def _filter_dict(d: dict) -> dict:
filtered = {}
for k, v in d.items():
if any(sensitive in k.lower() for sensitive in sensitive_keys):
filtered[k] = "***"
elif isinstance(v, 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]
else:
filtered[k] = v
return filtered
return _filter_dict(copy.deepcopy(config))
def get_vision_url(config: dict) -> str:
"""获取 vision URL
Args:
config: 配置字典
Returns:
str: vision URL
"""
server_config = config["server"]
vision_explain = server_config.get("vision_explain", "")
if "你的" in vision_explain:
local_ip = get_local_ip()
port = int(server_config.get("http_port", 8003))
vision_explain = f"http://{local_ip}:{port}/mcp/vision/explain"
return vision_explain
def is_valid_image_file(file_data: bytes) -> bool:
"""
检查文件数据是否为有效的图片格式
Args:
file_data: 文件的二进制数据
Returns:
bool: 如果是有效的图片格式返回True,否则返回False
"""
# 常见图片格式的魔数(文件头)
image_signatures = {
b"\xff\xd8\xff": "JPEG",
b"\x89PNG\r\n\x1a\n": "PNG",
b"GIF87a": "GIF",
b"GIF89a": "GIF",
b"BM": "BMP",
b"II*\x00": "TIFF",
b"MM\x00*": "TIFF",
b"RIFF": "WEBP",
}
# 检查文件头是否匹配任何已知的图片格式
for signature in image_signatures:
if file_data.startswith(signature):
return True
return False
2025-06-07 13:46:35 +08:00
def sanitize_tool_name(name: str) -> str:
"""Sanitize tool names for OpenAI compatibility."""
2025-07-01 16:07:35 +08:00
# 支持中文、英文字母、数字、下划线和连字符
return re.sub(r"[^a-zA-Z0-9_\-\u4e00-\u9fff]", "_", name)
def validate_mcp_endpoint(mcp_endpoint: str) -> bool:
"""
校验MCP接入点格式
Args:
mcp_endpoint: MCP接入点字符串
Returns:
bool: 是否有效
"""
# 1. 检查是否以ws开头
if not mcp_endpoint.startswith("ws"):
return False
# 2. 检查是否包含key、call字样
if "key" in mcp_endpoint.lower() or "call" in mcp_endpoint.lower():
return False
# 3. 检查是否包含/mcp/字样
if "/mcp/" not in mcp_endpoint:
return False
return True