From 4cc7247c3733c9c88f343c7432731e9b296e6a88 Mon Sep 17 00:00:00 2001
From: CGD <3030332422@qq.com>
Date: Wed, 4 Jun 2025 14:35:21 +0800
Subject: [PATCH 1/9] =?UTF-8?q?update:funasr=E5=AE=9E=E4=BE=8B=E4=B9=8B?=
=?UTF-8?q?=E5=89=8D=E6=B7=BB=E5=8A=A0=E5=86=85=E5=AD=98=E6=A3=80=E6=B5=8B?=
=?UTF-8?q?=E5=88=A4=E6=96=AD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
main/xiaozhi-server/core/providers/asr/fun_local.py | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/main/xiaozhi-server/core/providers/asr/fun_local.py b/main/xiaozhi-server/core/providers/asr/fun_local.py
index 8c7305c6..4b25a265 100644
--- a/main/xiaozhi-server/core/providers/asr/fun_local.py
+++ b/main/xiaozhi-server/core/providers/asr/fun_local.py
@@ -2,6 +2,7 @@ import time
import os
import sys
import io
+import psutil
from config.logger import setup_logging
from typing import Optional, Tuple, List
from core.providers.asr.base import ASRProviderBase
@@ -37,6 +38,13 @@ class CaptureOutput:
class ASRProvider(ASRProviderBase):
def __init__(self, config: dict, delete_audio_file: bool):
super().__init__()
+
+ # 内存检测,要求大于2G
+ min_mem_bytes = 2 * 1024 * 1024 * 1024
+ total_mem = psutil.virtual_memory().total
+ if total_mem < min_mem_bytes:
+ logger.bind(tag=TAG).error(f"可用内存不足2G,当前仅有 {total_mem / (1024*1024):.2f} MB")
+
self.interface_type = InterfaceType.LOCAL
self.model_dir = config.get("model_dir")
self.output_dir = config.get("output_dir") # 修正配置键名
From 0268f90e9c69ae0ac9556cdcbebf59946ce76938 Mon Sep 17 00:00:00 2001
From: CGD <3030332422@qq.com>
Date: Wed, 4 Jun 2025 14:46:52 +0800
Subject: [PATCH 2/9] =?UTF-8?q?update=EF=BC=9A=E4=BF=AE=E6=94=B9=E6=97=A5?=
=?UTF-8?q?=E5=BF=97=E6=A8=A1=E5=9D=97=EF=BC=8C=E4=BD=BF=E5=85=B6=E6=8C=89?=
=?UTF-8?q?=E6=97=A5=E6=9C=9F=E5=92=8C=E5=A4=A7=E5=B0=8F=E5=88=86=E5=89=B2?=
=?UTF-8?q?=E7=9A=84=E6=97=A5=E5=BF=97=E6=96=87=E4=BB=B6=E5=90=8D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
main/xiaozhi-server/config/logger.py | 91 +++++++++++++++++++++++-----
1 file changed, 75 insertions(+), 16 deletions(-)
diff --git a/main/xiaozhi-server/config/logger.py b/main/xiaozhi-server/config/logger.py
index 5f107f5d..e5bf8466 100644
--- a/main/xiaozhi-server/config/logger.py
+++ b/main/xiaozhi-server/config/logger.py
@@ -3,15 +3,16 @@ import sys
from loguru import logger
from config.config_loader import load_config
from config.settings import check_config_file
+from datetime import datetime
SERVER_VERSION = "0.5.2"
_logger_initialized = False
-
+_current_log_file = None
+_current_log_size = 0
+_max_log_size = 10 * 1024 * 1024 # 10MB
def get_module_abbreviation(module_name, module_dict):
- """获取模块名称的缩写,如果为空则返回00
- 如果名称中包含下划线,则返回下划线后面的前两个字符
- """
+ """获取模块名称的缩写,如果为空则返回00"""
module_value = module_dict.get(module_name, "")
if not module_value:
return "00"
@@ -20,7 +21,6 @@ def get_module_abbreviation(module_name, module_dict):
return parts[-1][:2] if parts[-1] else "00"
return module_value[:2]
-
def build_module_string(selected_module):
"""构建模块字符串"""
return (
@@ -32,12 +32,59 @@ def build_module_string(selected_module):
+ get_module_abbreviation("Intent", selected_module)
)
-
def formatter(record):
"""为没有 tag 的日志添加默认值"""
record["extra"].setdefault("tag", record["name"])
return record["message"]
+def get_log_filename(log_dir, base_name):
+ """生成按日期和大小分割的日志文件名"""
+ global _current_log_file, _current_log_size
+
+ now = datetime.now()
+ date_part = f"{now.month}.{now.day}"
+
+ # 检查是否需要创建新文件
+ if _current_log_file is None or not os.path.exists(_current_log_file):
+ file_index = 1
+ while True:
+ new_filename = os.path.join(log_dir, f"{base_name}.{date_part}.{file_index}")
+ if not os.path.exists(new_filename):
+ _current_log_file = new_filename
+ _current_log_size = 0
+ return new_filename
+ # 如果文件已存在,增加索引
+ file_index += 1
+ else:
+ # 检查文件大小
+ if _current_log_size > _max_log_size:
+ # 文件过大,创建新文件
+ base_path = os.path.splitext(_current_log_file)[0]
+ parts = base_path.split('.')
+ if len(parts) >= 3:
+ try:
+ file_index = int(parts[-1]) + 1
+ except ValueError:
+ file_index = 1
+ else:
+ file_index = 1
+
+ new_filename = f"{base_path.rsplit('.', 1)[0]}.{file_index}"
+ _current_log_file = new_filename
+ _current_log_size = 0
+ return new_filename
+ else:
+ return _current_log_file
+
+def update_log_size(message):
+ """更新当前日志文件大小"""
+ global _current_log_size
+ # 如果当前日志文件存在,获取其大小
+ if _current_log_file and os.path.exists(_current_log_file):
+ _current_log_size = os.path.getsize(_current_log_file)
+ else:
+ # 如果当前日志文件不存在,重置大小
+ _current_log_size = len(message.encode('utf-8'))
def setup_logging():
check_config_file()
@@ -52,7 +99,7 @@ def setup_logging():
extra={
"selected_module": log_config.get("selected_module", "00000000000000")
}
- ) # 新增配置
+ )
log_format = log_config.get(
"log_format",
"{time:YYMMDD HH:mm:ss}[{version}_{extra[selected_module]}][{extra[tag]}]-{level}-{message}",
@@ -72,7 +119,7 @@ def setup_logging():
log_level = log_config.get("log_level", "INFO")
log_dir = log_config.get("log_dir", "tmp")
- log_file = log_config.get("log_file", "server.log")
+ log_file = log_config.get("log_file", "server")
data_dir = log_config.get("data_dir", "data")
os.makedirs(log_dir, exist_ok=True)
@@ -84,18 +131,23 @@ def setup_logging():
# 输出到控制台
logger.add(sys.stdout, format=log_format, level=log_level, filter=formatter)
- # 输出到文件
+ # 输出到文件,使用自定义的日志文件名生成器
+ def sink(message):
+ filename = get_log_filename(log_dir, log_file)
+ with open(filename, "a", encoding="utf-8") as f:
+ f.write(message + "\n")
+ update_log_size(message)
+
logger.add(
- os.path.join(log_dir, log_file),
+ sink,
format=log_format_file,
level=log_level,
filter=formatter,
)
- _logger_initialized = True # 标记为已初始化
+ _logger_initialized = True
return logger
-
def update_module_string(selected_module_str):
"""更新模块字符串并重新配置日志处理器"""
logger.debug(f"更新日志配置组件")
@@ -126,6 +178,9 @@ def update_module_string(selected_module_str):
"{selected_module}", selected_module_str
)
+ log_dir = log_config.get("log_dir", "tmp")
+ log_file = log_config.get("log_file", "server")
+
logger.remove()
logger.add(
sys.stdout,
@@ -133,11 +188,15 @@ def update_module_string(selected_module_str):
level=log_config.get("log_level", "INFO"),
filter=formatter,
)
+
+ def sink(message):
+ filename = get_log_filename(log_dir, log_file)
+ with open(filename, "a", encoding="utf-8") as f:
+ f.write(message + "\n")
+ update_log_size(message)
+
logger.add(
- os.path.join(
- log_config.get("log_dir", "tmp"),
- log_config.get("log_file", "server.log"),
- ),
+ sink,
format=log_format_file,
level=log_config.get("log_level", "INFO"),
filter=formatter,
From fba758ea36024629236e80359fc9d8f8dbc9f68f Mon Sep 17 00:00:00 2001
From: CGD <3030332422@qq.com>
Date: Wed, 4 Jun 2025 15:42:52 +0800
Subject: [PATCH 3/9] =?UTF-8?q?update:=E6=81=A2=E5=A4=8D=E6=9C=80=E5=88=9D?=
=?UTF-8?q?=E7=9A=84=E6=97=A5=E5=BF=97=E6=A8=A1=E5=9D=97?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
main/xiaozhi-server/config/logger.py | 93 +++++-----------------------
1 file changed, 17 insertions(+), 76 deletions(-)
diff --git a/main/xiaozhi-server/config/logger.py b/main/xiaozhi-server/config/logger.py
index e5bf8466..643952dd 100644
--- a/main/xiaozhi-server/config/logger.py
+++ b/main/xiaozhi-server/config/logger.py
@@ -3,16 +3,15 @@ import sys
from loguru import logger
from config.config_loader import load_config
from config.settings import check_config_file
-from datetime import datetime
-SERVER_VERSION = "0.5.2"
+SERVER_VERSION = "0.5.4"
_logger_initialized = False
-_current_log_file = None
-_current_log_size = 0
-_max_log_size = 10 * 1024 * 1024 # 10MB
+
def get_module_abbreviation(module_name, module_dict):
- """获取模块名称的缩写,如果为空则返回00"""
+ """获取模块名称的缩写,如果为空则返回00
+ 如果名称中包含下划线,则返回下划线后面的前两个字符
+ """
module_value = module_dict.get(module_name, "")
if not module_value:
return "00"
@@ -21,6 +20,7 @@ def get_module_abbreviation(module_name, module_dict):
return parts[-1][:2] if parts[-1] else "00"
return module_value[:2]
+
def build_module_string(selected_module):
"""构建模块字符串"""
return (
@@ -32,59 +32,12 @@ def build_module_string(selected_module):
+ get_module_abbreviation("Intent", selected_module)
)
+
def formatter(record):
"""为没有 tag 的日志添加默认值"""
record["extra"].setdefault("tag", record["name"])
return record["message"]
-def get_log_filename(log_dir, base_name):
- """生成按日期和大小分割的日志文件名"""
- global _current_log_file, _current_log_size
-
- now = datetime.now()
- date_part = f"{now.month}.{now.day}"
-
- # 检查是否需要创建新文件
- if _current_log_file is None or not os.path.exists(_current_log_file):
- file_index = 1
- while True:
- new_filename = os.path.join(log_dir, f"{base_name}.{date_part}.{file_index}")
- if not os.path.exists(new_filename):
- _current_log_file = new_filename
- _current_log_size = 0
- return new_filename
- # 如果文件已存在,增加索引
- file_index += 1
- else:
- # 检查文件大小
- if _current_log_size > _max_log_size:
- # 文件过大,创建新文件
- base_path = os.path.splitext(_current_log_file)[0]
- parts = base_path.split('.')
- if len(parts) >= 3:
- try:
- file_index = int(parts[-1]) + 1
- except ValueError:
- file_index = 1
- else:
- file_index = 1
-
- new_filename = f"{base_path.rsplit('.', 1)[0]}.{file_index}"
- _current_log_file = new_filename
- _current_log_size = 0
- return new_filename
- else:
- return _current_log_file
-
-def update_log_size(message):
- """更新当前日志文件大小"""
- global _current_log_size
- # 如果当前日志文件存在,获取其大小
- if _current_log_file and os.path.exists(_current_log_file):
- _current_log_size = os.path.getsize(_current_log_file)
- else:
- # 如果当前日志文件不存在,重置大小
- _current_log_size = len(message.encode('utf-8'))
def setup_logging():
check_config_file()
@@ -99,7 +52,7 @@ def setup_logging():
extra={
"selected_module": log_config.get("selected_module", "00000000000000")
}
- )
+ ) # 新增配置
log_format = log_config.get(
"log_format",
"{time:YYMMDD HH:mm:ss}[{version}_{extra[selected_module]}][{extra[tag]}]-{level}-{message}",
@@ -119,7 +72,7 @@ def setup_logging():
log_level = log_config.get("log_level", "INFO")
log_dir = log_config.get("log_dir", "tmp")
- log_file = log_config.get("log_file", "server")
+ log_file = log_config.get("log_file", "server.log")
data_dir = log_config.get("data_dir", "data")
os.makedirs(log_dir, exist_ok=True)
@@ -131,23 +84,18 @@ def setup_logging():
# 输出到控制台
logger.add(sys.stdout, format=log_format, level=log_level, filter=formatter)
- # 输出到文件,使用自定义的日志文件名生成器
- def sink(message):
- filename = get_log_filename(log_dir, log_file)
- with open(filename, "a", encoding="utf-8") as f:
- f.write(message + "\n")
- update_log_size(message)
-
+ # 输出到文件
logger.add(
- sink,
+ os.path.join(log_dir, log_file),
format=log_format_file,
level=log_level,
filter=formatter,
)
- _logger_initialized = True
+ _logger_initialized = True # 标记为已初始化
return logger
+
def update_module_string(selected_module_str):
"""更新模块字符串并重新配置日志处理器"""
logger.debug(f"更新日志配置组件")
@@ -178,9 +126,6 @@ def update_module_string(selected_module_str):
"{selected_module}", selected_module_str
)
- log_dir = log_config.get("log_dir", "tmp")
- log_file = log_config.get("log_file", "server")
-
logger.remove()
logger.add(
sys.stdout,
@@ -188,15 +133,11 @@ def update_module_string(selected_module_str):
level=log_config.get("log_level", "INFO"),
filter=formatter,
)
-
- def sink(message):
- filename = get_log_filename(log_dir, log_file)
- with open(filename, "a", encoding="utf-8") as f:
- f.write(message + "\n")
- update_log_size(message)
-
logger.add(
- sink,
+ os.path.join(
+ log_config.get("log_dir", "tmp"),
+ log_config.get("log_file", "server.log"),
+ ),
format=log_format_file,
level=log_config.get("log_level", "INFO"),
filter=formatter,
From 23cb7616d920dc3b07a6ae813fe7b334ca1cd4ad Mon Sep 17 00:00:00 2001
From: Sakura-RanChen <1908198662@qq.com>
Date: Wed, 4 Jun 2025 16:46:49 +0800
Subject: [PATCH 4/9] =?UTF-8?q?update:=20=E6=9B=B4=E6=94=B9to=5Ftts?=
=?UTF-8?q?=E4=BF=9D=E5=AD=98=E4=B8=B4=E6=97=B6=E6=96=87=E4=BB=B6=E5=88=A4?=
=?UTF-8?q?=E6=96=AD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../xiaozhi-server/core/handle/helloHandle.py | 32 +++----
.../core/providers/tts/aliyun.py | 11 ++-
.../xiaozhi-server/core/providers/tts/base.py | 95 +++++++++++++------
.../core/providers/tts/cozecn.py | 11 ++-
.../core/providers/tts/custom.py | 9 +-
.../core/providers/tts/doubao.py | 12 ++-
.../xiaozhi-server/core/providers/tts/edge.py | 25 +++--
.../core/providers/tts/fishspeech.py | 9 +-
.../core/providers/tts/gpt_sovits_v2.py | 8 +-
.../core/providers/tts/gpt_sovits_v3.py | 8 +-
.../core/providers/tts/minimax.py | 9 +-
.../core/providers/tts/openai.py | 10 +-
.../core/providers/tts/siliconflow.py | 10 +-
.../core/providers/tts/tencent.py | 15 +--
.../core/providers/tts/ttson.py | 9 +-
main/xiaozhi-server/core/utils/p3.py | 26 +++++
main/xiaozhi-server/core/utils/util.py | 46 +++++++++
17 files changed, 253 insertions(+), 92 deletions(-)
diff --git a/main/xiaozhi-server/core/handle/helloHandle.py b/main/xiaozhi-server/core/handle/helloHandle.py
index 6b8cb4fd..859059b6 100644
--- a/main/xiaozhi-server/core/handle/helloHandle.py
+++ b/main/xiaozhi-server/core/handle/helloHandle.py
@@ -2,10 +2,9 @@ import os
import time
import json
import random
-import shutil
import asyncio
from core.handle.sendAudioHandle import send_stt_message
-from core.utils.util import remove_punctuation_and_length
+from core.utils.util import remove_punctuation_and_length, opus_datas_to_wav_bytes
from core.providers.tts.dto.dto import ContentType, InterfaceType
from core.handle.mcpHandle import (
MCPClient,
@@ -119,19 +118,18 @@ async def wakeupWordsResponse(conn):
result = conn.llm.response_no_stream(conn.config["prompt"], question)
if result is None or result == "":
return
- tts_file = await asyncio.to_thread(conn.tts.to_tts, result)
- if tts_file is not None and os.path.exists(tts_file):
- file_type = os.path.splitext(tts_file)[1]
- if file_type:
- file_type = file_type.lstrip(".")
- old_file = getWakeupWordFile("my_" + WAKEUP_CONFIG["file_name"])
- if old_file is not None:
- os.remove(old_file)
- """将文件挪到"wakeup_words.mp3"""
- shutil.move(
- tts_file,
- WAKEUP_CONFIG["dir"] + "my_" + WAKEUP_CONFIG["file_name"] + "." + file_type,
- )
- WAKEUP_CONFIG["create_time"] = time.time()
- WAKEUP_CONFIG["text"] = result
+ opus_datas = await asyncio.to_thread(conn.tts.to_tts, result)
+ if not opus_datas:
+ return
+
+ wav_bytes = opus_datas_to_wav_bytes(opus_datas, sample_rate=16000)
+ file_path = os.path.join(
+ WAKEUP_CONFIG["dir"], "my_" + WAKEUP_CONFIG["file_name"] + ".wav"
+ )
+ # 写入wav数据
+ with open(file_path, "wb") as f:
+ f.write(wav_bytes)
+
+ WAKEUP_CONFIG["create_time"] = time.time()
+ WAKEUP_CONFIG["text"] = result
diff --git a/main/xiaozhi-server/core/providers/tts/aliyun.py b/main/xiaozhi-server/core/providers/tts/aliyun.py
index 1fda8d77..c9b3d130 100644
--- a/main/xiaozhi-server/core/providers/tts/aliyun.py
+++ b/main/xiaozhi-server/core/providers/tts/aliyun.py
@@ -91,7 +91,7 @@ class TTSProvider(TTSProviderBase):
self.appkey = config.get("appkey")
self.format = config.get("format", "wav")
-
+ self.audio_file_type = config.get("format", "wav")
sample_rate = config.get("sample_rate", "16000")
self.sample_rate = int(sample_rate) if sample_rate else 16000
@@ -188,9 +188,12 @@ class TTSProvider(TTSProviderBase):
)
# 检查返回请求数据的mime类型是否是audio/***,是则保存到指定路径下;返回的是binary格式的
if resp.headers["Content-Type"].startswith("audio/"):
- with open(output_file, "wb") as f:
- f.write(resp.content)
- return output_file
+ if output_file:
+ with open(output_file, "wb") as f:
+ f.write(resp.content)
+ return output_file
+ else:
+ return resp.content
else:
raise Exception(
f"{__name__} status_code: {resp.status_code} response: {resp.content}"
diff --git a/main/xiaozhi-server/core/providers/tts/base.py b/main/xiaozhi-server/core/providers/tts/base.py
index 9c094f24..b711659b 100644
--- a/main/xiaozhi-server/core/providers/tts/base.py
+++ b/main/xiaozhi-server/core/providers/tts/base.py
@@ -8,7 +8,7 @@ from datetime import datetime
from core.utils import textUtils
from abc import ABC, abstractmethod
from config.logger import setup_logging
-from core.utils.util import audio_to_data
+from core.utils.util import audio_to_data, audio_bytes_to_data
from core.utils.tts import MarkdownCleaner
from core.utils.output_counter import add_device_output
from core.handle.reportHandle import enqueue_tts_report
@@ -20,7 +20,6 @@ from core.providers.tts.dto.dto import (
InterfaceType,
)
-
import traceback
TAG = __name__
@@ -33,6 +32,7 @@ class TTSProviderBase(ABC):
self.conn = None
self.tts_timeout = 10
self.delete_audio_file = delete_audio_file
+ self.audio_file_type = "wav"
self.output_file = config.get("output_dir", "tmp/")
self.tts_text_queue = queue.Queue()
self.tts_audio_queue = queue.Queue()
@@ -76,35 +76,60 @@ class TTSProviderBase(ABC):
)
def to_tts(self, text):
- tmp_file = self.generate_filename()
- try:
- max_repeat_time = 5
- text = MarkdownCleaner.clean_markdown(text)
- while not os.path.exists(tmp_file) and max_repeat_time > 0:
+ text = MarkdownCleaner.clean_markdown(text)
+ max_repeat_time = 5
+ if self.delete_audio_file:
+ # 需要删除文件的直接转为音频数据
+ while max_repeat_time > 0:
try:
- asyncio.run(self.text_to_speak(text, tmp_file))
+ audio_bytes = asyncio.run(self.text_to_speak(text, None))
+ if audio_bytes:
+ audio_datas, _ = audio_bytes_to_data(audio_bytes, file_type=self.audio_file_type, is_opus=True)
+ return audio_datas
+ else:
+ max_repeat_time -= 1
except Exception as e:
logger.bind(tag=TAG).warning(
f"语音生成失败{5 - max_repeat_time + 1}次: {text},错误: {e}"
)
- # 未执行成功,删除文件
- if os.path.exists(tmp_file):
- os.remove(tmp_file)
max_repeat_time -= 1
-
if max_repeat_time > 0:
logger.bind(tag=TAG).info(
- f"语音生成成功: {text}:{tmp_file},重试{5 - max_repeat_time}次"
+ f"语音生成成功: {text},重试{5 - max_repeat_time}次"
)
else:
logger.bind(tag=TAG).error(
f"语音生成失败: {text},请检查网络或服务是否正常"
)
-
- return tmp_file
- except Exception as e:
- logger.bind(tag=TAG).error(f"Failed to generate TTS file: {e}")
return None
+ else:
+ tmp_file = self.generate_filename()
+ try:
+ while not os.path.exists(tmp_file) and max_repeat_time > 0:
+ try:
+ asyncio.run(self.text_to_speak(text, tmp_file))
+ except Exception as e:
+ logger.bind(tag=TAG).warning(
+ f"语音生成失败{5 - max_repeat_time + 1}次: {text},错误: {e}"
+ )
+ # 未执行成功,删除文件
+ if os.path.exists(tmp_file):
+ os.remove(tmp_file)
+ max_repeat_time -= 1
+
+ if max_repeat_time > 0:
+ logger.bind(tag=TAG).info(
+ f"语音生成成功: {text}:{tmp_file},重试{5 - max_repeat_time}次"
+ )
+ else:
+ logger.bind(tag=TAG).error(
+ f"语音生成失败: {text},请检查网络或服务是否正常"
+ )
+
+ return tmp_file
+ except Exception as e:
+ logger.bind(tag=TAG).error(f"Failed to generate TTS file: {e}")
+ return None
@abstractmethod
async def text_to_speak(self, text, output_file):
@@ -192,12 +217,19 @@ class TTSProviderBase(ABC):
self.tts_text_buff.append(message.content_detail)
segment_text = self._get_segment_text()
if segment_text:
- tts_file = self.to_tts(segment_text)
- if tts_file:
- audio_datas = self._process_audio_file(tts_file)
- self.tts_audio_queue.put(
- (message.sentence_type, audio_datas, segment_text)
- )
+ if self.delete_audio_file:
+ audio_datas = self.to_tts(segment_text)
+ if audio_datas:
+ self.tts_audio_queue.put(
+ (message.sentence_type, audio_datas, segment_text)
+ )
+ else:
+ tts_file = self.to_tts(segment_text)
+ if tts_file:
+ audio_datas = self._process_audio_file(tts_file)
+ self.tts_audio_queue.put(
+ (message.sentence_type, audio_datas, segment_text)
+ )
elif ContentType.FILE == message.content_type:
self._process_remaining_text()
tts_file = message.content_file
@@ -334,11 +366,18 @@ class TTSProviderBase(ABC):
if remaining_text:
segment_text = textUtils.get_string_no_punctuation_or_emoji(remaining_text)
if segment_text:
- tts_file = self.to_tts(segment_text)
- audio_datas = self._process_audio_file(tts_file)
- self.tts_audio_queue.put(
- (SentenceType.MIDDLE, audio_datas, segment_text)
- )
+ if self.delete_audio_file:
+ audio_datas = self.to_tts(segment_text)
+ if audio_datas:
+ self.tts_audio_queue.put(
+ (SentenceType.MIDDLE, audio_datas, segment_text)
+ )
+ else:
+ tts_file = self.to_tts(segment_text)
+ audio_datas = self._process_audio_file(tts_file)
+ self.tts_audio_queue.put(
+ (SentenceType.MIDDLE, audio_datas, segment_text)
+ )
self.processed_chars += len(full_text)
return True
return False
diff --git a/main/xiaozhi-server/core/providers/tts/cozecn.py b/main/xiaozhi-server/core/providers/tts/cozecn.py
index c3019b67..ede08928 100644
--- a/main/xiaozhi-server/core/providers/tts/cozecn.py
+++ b/main/xiaozhi-server/core/providers/tts/cozecn.py
@@ -11,8 +11,8 @@ class TTSProvider(TTSProviderBase):
self.voice = config.get("private_voice")
else:
self.voice = config.get("voice")
- self.response_format = config.get("response_format")
-
+ self.response_format = config.get("response_format", "mp3")
+ self.audio_file_type = config.get("response_format", "mp3")
self.host = "api.coze.cn"
self.api_url = f"https://{self.host}/v1/audio/speech"
@@ -33,7 +33,10 @@ class TTSProvider(TTSProviderBase):
"POST", self.api_url, json=request_json, headers=headers
)
data = response.content
- file_to_save = open(output_file, "wb")
- file_to_save.write(data)
+ if output_file:
+ with open(output_file, "wb") as file_to_save:
+ file_to_save.write(data)
+ else:
+ return data
except Exception as e:
raise Exception(f"{__name__} error: {e}")
diff --git a/main/xiaozhi-server/core/providers/tts/custom.py b/main/xiaozhi-server/core/providers/tts/custom.py
index a5300a3e..b417825e 100644
--- a/main/xiaozhi-server/core/providers/tts/custom.py
+++ b/main/xiaozhi-server/core/providers/tts/custom.py
@@ -16,8 +16,8 @@ class TTSProvider(TTSProviderBase):
self.method = config.get("method", "GET")
self.headers = config.get("headers", {})
self.format = config.get("format", "wav")
+ self.audio_file_type = config.get("format", "wav")
self.output_file = config.get("output_dir", "tmp/")
-
self.params = config.get("params")
if isinstance(self.params, str):
@@ -43,8 +43,11 @@ class TTSProvider(TTSProviderBase):
else:
resp = requests.get(self.url, params=request_params, headers=self.headers)
if resp.status_code == 200:
- with open(output_file, "wb") as file:
- file.write(resp.content)
+ if output_file:
+ with open(output_file, "wb") as file:
+ file.write(resp.content)
+ else:
+ return resp.content
else:
error_msg = f"Custom TTS请求失败: {resp.status_code} - {resp.text}"
logger.bind(tag=TAG).error(error_msg)
diff --git a/main/xiaozhi-server/core/providers/tts/doubao.py b/main/xiaozhi-server/core/providers/tts/doubao.py
index 78058c31..1a21ffa1 100644
--- a/main/xiaozhi-server/core/providers/tts/doubao.py
+++ b/main/xiaozhi-server/core/providers/tts/doubao.py
@@ -29,7 +29,7 @@ class TTSProvider(TTSProviderBase):
speed_ratio = config.get("speed_ratio", "1.0")
volume_ratio = config.get("volume_ratio", "1.0")
pitch_ratio = config.get("pitch_ratio", "1.0")
-
+ self.audio_file_type = config.get("format", "wav")
self.speed_ratio = float(speed_ratio) if speed_ratio else 1.0
self.volume_ratio = float(volume_ratio) if volume_ratio else 1.0
self.pitch_ratio = float(pitch_ratio) if pitch_ratio else 1.0
@@ -49,7 +49,7 @@ class TTSProvider(TTSProviderBase):
"user": {"uid": "1"},
"audio": {
"voice_type": self.voice,
- "encoding": "wav",
+ "encoding": self.audio_file_type,
"speed_ratio": self.speed_ratio,
"volume_ratio": self.volume_ratio,
"pitch_ratio": self.pitch_ratio,
@@ -70,8 +70,12 @@ class TTSProvider(TTSProviderBase):
)
if "data" in resp.json():
data = resp.json()["data"]
- file_to_save = open(output_file, "wb")
- file_to_save.write(base64.b64decode(data))
+ audio_bytes = base64.b64decode(data)
+ if output_file:
+ with open(output_file, "wb") as file_to_save:
+ file_to_save.write(audio_bytes)
+ else:
+ return audio_bytes
else:
raise Exception(
f"{__name__} status_code: {resp.status_code} response: {resp.content}"
diff --git a/main/xiaozhi-server/core/providers/tts/edge.py b/main/xiaozhi-server/core/providers/tts/edge.py
index 3d9b2547..ffa1c3aa 100644
--- a/main/xiaozhi-server/core/providers/tts/edge.py
+++ b/main/xiaozhi-server/core/providers/tts/edge.py
@@ -12,6 +12,7 @@ class TTSProvider(TTSProviderBase):
self.voice = config.get("private_voice")
else:
self.voice = config.get("voice")
+ self.audio_file_type = config.get("format", "mp3")
def generate_filename(self, extension=".mp3"):
return os.path.join(
@@ -22,16 +23,24 @@ class TTSProvider(TTSProviderBase):
async def text_to_speak(self, text, output_file):
try:
communicate = edge_tts.Communicate(text, voice=self.voice)
- # 确保目录存在并创建空文件
- os.makedirs(os.path.dirname(output_file), exist_ok=True)
- with open(output_file, "wb") as f:
- pass
+ if output_file:
+ # 确保目录存在并创建空文件
+ os.makedirs(os.path.dirname(output_file), exist_ok=True)
+ with open(output_file, "wb") as f:
+ pass
- # 流式写入音频数据
- with open(output_file, "ab") as f: # 改为追加模式避免覆盖
+ # 流式写入音频数据
+ with open(output_file, "ab") as f: # 改为追加模式避免覆盖
+ async for chunk in communicate.stream():
+ if chunk["type"] == "audio": # 只处理音频数据块
+ f.write(chunk["data"])
+ else:
+ # 返回音频二进制数据
+ audio_bytes = b""
async for chunk in communicate.stream():
- if chunk["type"] == "audio": # 只处理音频数据块
- f.write(chunk["data"])
+ if chunk["type"] == "audio":
+ audio_bytes += chunk["data"]
+ return audio_bytes
except Exception as e:
error_msg = f"Edge TTS请求失败: {e}"
raise Exception(error_msg) # 抛出异常,让调用方捕获
\ No newline at end of file
diff --git a/main/xiaozhi-server/core/providers/tts/fishspeech.py b/main/xiaozhi-server/core/providers/tts/fishspeech.py
index 23b20e76..3bcb1229 100644
--- a/main/xiaozhi-server/core/providers/tts/fishspeech.py
+++ b/main/xiaozhi-server/core/providers/tts/fishspeech.py
@@ -88,7 +88,7 @@ class TTSProvider(TTSProviderBase):
self.reference_audio = parse_string_to_list(config.get("reference_audio"))
self.reference_text = parse_string_to_list(config.get("reference_text"))
self.format = config.get("response_format", "wav")
-
+ self.audio_file_type = config.get("response_format", "wav")
self.api_key = config.get("api_key", "YOUR_API_KEY")
have_key = check_model_key("FishSpeech TTS", self.api_key)
if not have_key:
@@ -170,8 +170,11 @@ class TTSProvider(TTSProviderBase):
if response.status_code == 200:
audio_content = response.content
- with open(output_file, "wb") as audio_file:
- audio_file.write(audio_content)
+ if output_file:
+ with open(output_file, "wb") as audio_file:
+ audio_file.write(audio_content)
+ else:
+ return audio_content
else:
error_msg = f"Request failed with status code {response.status_code}"
diff --git a/main/xiaozhi-server/core/providers/tts/gpt_sovits_v2.py b/main/xiaozhi-server/core/providers/tts/gpt_sovits_v2.py
index e76b3f22..7f43ad7d 100644
--- a/main/xiaozhi-server/core/providers/tts/gpt_sovits_v2.py
+++ b/main/xiaozhi-server/core/providers/tts/gpt_sovits_v2.py
@@ -65,6 +65,7 @@ class TTSProvider(TTSProviderBase):
self.aux_ref_audio_paths = parse_string_to_list(
config.get("aux_ref_audio_paths")
)
+ self.audio_file_type = config.get("format", "wav")
async def text_to_speak(self, text, output_file):
request_json = {
@@ -91,8 +92,11 @@ class TTSProvider(TTSProviderBase):
resp = requests.post(self.url, json=request_json)
if resp.status_code == 200:
- with open(output_file, "wb") as file:
- file.write(resp.content)
+ if output_file:
+ with open(output_file, "wb") as file:
+ file.write(resp.content)
+ else:
+ return resp.content
else:
error_msg = f"GPT_SoVITS_V2 TTS请求失败: {resp.status_code} - {resp.text}"
logger.bind(tag=TAG).error(error_msg)
diff --git a/main/xiaozhi-server/core/providers/tts/gpt_sovits_v3.py b/main/xiaozhi-server/core/providers/tts/gpt_sovits_v3.py
index 6b66efc3..ae41fc4e 100644
--- a/main/xiaozhi-server/core/providers/tts/gpt_sovits_v3.py
+++ b/main/xiaozhi-server/core/providers/tts/gpt_sovits_v3.py
@@ -32,6 +32,7 @@ class TTSProvider(TTSProviderBase):
self.cut_punc = config.get("cut_punc", "")
self.inp_refs = parse_string_to_list(config.get("inp_refs"))
self.if_sr = str(config.get("if_sr", False)).lower() in ("true", "1", "yes")
+ self.audio_file_type = config.get("format", "wav")
async def text_to_speak(self, text, output_file):
request_params = {
@@ -52,8 +53,11 @@ class TTSProvider(TTSProviderBase):
resp = requests.get(self.url, params=request_params)
if resp.status_code == 200:
- with open(output_file, "wb") as file:
- file.write(resp.content)
+ if output_file:
+ with open(output_file, "wb") as file:
+ file.write(resp.content)
+ else:
+ return resp.content
else:
error_msg = f"GPT_SoVITS_V3 TTS请求失败: {resp.status_code} - {resp.text}"
logger.bind(tag=TAG).error(error_msg)
diff --git a/main/xiaozhi-server/core/providers/tts/minimax.py b/main/xiaozhi-server/core/providers/tts/minimax.py
index dd406b64..3741d169 100644
--- a/main/xiaozhi-server/core/providers/tts/minimax.py
+++ b/main/xiaozhi-server/core/providers/tts/minimax.py
@@ -52,6 +52,7 @@ class TTSProvider(TTSProviderBase):
"Content-Type": "application/json",
"Authorization": f"Bearer {self.api_key}",
}
+ self.audio_file_type = defult_audio_setting.get("format", "mp3")
def generate_filename(self, extension=".mp3"):
return os.path.join(
@@ -80,8 +81,12 @@ class TTSProvider(TTSProviderBase):
# 检查返回请求数据的status_code是否为0
if resp.json()["base_resp"]["status_code"] == 0:
data = resp.json()["data"]["audio"]
- file_to_save = open(output_file, "wb")
- file_to_save.write(bytes.fromhex(data))
+ audio_bytes = bytes.fromhex(data)
+ if output_file:
+ with open(output_file, "wb") as file_to_save:
+ file_to_save.write(audio_bytes)
+ else:
+ return audio_bytes
else:
raise Exception(
f"{__name__} status_code: {resp.status_code} response: {resp.content}"
diff --git a/main/xiaozhi-server/core/providers/tts/openai.py b/main/xiaozhi-server/core/providers/tts/openai.py
index bdbc9174..e5155513 100644
--- a/main/xiaozhi-server/core/providers/tts/openai.py
+++ b/main/xiaozhi-server/core/providers/tts/openai.py
@@ -17,7 +17,8 @@ class TTSProvider(TTSProviderBase):
self.voice = config.get("private_voice")
else:
self.voice = config.get("voice", "alloy")
- self.response_format = "wav"
+ self.response_format = config.get("format", "wav")
+ self.audio_file_type = config.get("format", "wav")
# 处理空字符串的情况
speed = config.get("speed", "1.0")
@@ -40,8 +41,11 @@ class TTSProvider(TTSProviderBase):
}
response = requests.post(self.api_url, json=data, headers=headers)
if response.status_code == 200:
- with open(output_file, "wb") as audio_file:
- audio_file.write(response.content)
+ if output_file:
+ with open(output_file, "wb") as audio_file:
+ audio_file.write(response.content)
+ else:
+ return response.content
else:
raise Exception(
f"OpenAI TTS请求失败: {response.status_code} - {response.text}"
diff --git a/main/xiaozhi-server/core/providers/tts/siliconflow.py b/main/xiaozhi-server/core/providers/tts/siliconflow.py
index 8f7fd641..b85cebf4 100644
--- a/main/xiaozhi-server/core/providers/tts/siliconflow.py
+++ b/main/xiaozhi-server/core/providers/tts/siliconflow.py
@@ -11,7 +11,8 @@ class TTSProvider(TTSProviderBase):
self.voice = config.get("private_voice")
else:
self.voice = config.get("voice")
- self.response_format = config.get("response_format")
+ self.response_format = config.get("response_format", "mp3")
+ self.audio_file_type = config.get("response_format", "mp3")
self.sample_rate = config.get("sample_rate")
self.speed = float(config.get("speed", 1.0))
self.gain = config.get("gain")
@@ -35,7 +36,10 @@ class TTSProvider(TTSProviderBase):
"POST", self.api_url, json=request_json, headers=headers
)
data = response.content
- file_to_save = open(output_file, "wb")
- file_to_save.write(data)
+ if output_file:
+ with open(output_file, "wb") as file_to_save:
+ file_to_save.write(data)
+ else:
+ return data
except Exception as e:
raise Exception(f"{__name__} error: {e}")
diff --git a/main/xiaozhi-server/core/providers/tts/tencent.py b/main/xiaozhi-server/core/providers/tts/tencent.py
index 7a55328a..a2493632 100644
--- a/main/xiaozhi-server/core/providers/tts/tencent.py
+++ b/main/xiaozhi-server/core/providers/tts/tencent.py
@@ -22,6 +22,7 @@ class TTSProvider(TTSProviderBase):
self.api_url = "https://tts.tencentcloudapi.com" # 正确的API端点
self.region = config.get("region")
self.output_file = config.get("output_dir")
+ self.audio_file_type = config.get("format", "wav")
def _get_auth_headers(self, request_body):
"""生成鉴权请求头"""
@@ -148,12 +149,14 @@ class TTSProvider(TTSProviderBase):
f"API返回错误: {error_info['Code']}: {error_info['Message']}"
)
- # 提取音频数据
- audio_data = response_data["Response"].get("Audio")
- if audio_data:
- # 解码Base64音频数据并保存
- with open(output_file, "wb") as f:
- f.write(base64.b64decode(audio_data))
+ # 解码Base64音频数据
+ audio_bytes = base64.b64decode(response_data["Response"].get("Audio"))
+ if audio_bytes:
+ if output_file:
+ with open(output_file, "wb") as f:
+ f.write(audio_bytes)
+ else:
+ return audio_bytes
else:
raise Exception(f"{__name__}: 没有返回音频数据: {response_data}")
else:
diff --git a/main/xiaozhi-server/core/providers/tts/ttson.py b/main/xiaozhi-server/core/providers/tts/ttson.py
index b3c7797f..c11206a6 100644
--- a/main/xiaozhi-server/core/providers/tts/ttson.py
+++ b/main/xiaozhi-server/core/providers/tts/ttson.py
@@ -30,6 +30,7 @@ class TTSProvider(TTSProviderBase):
self.output_file = config.get("output_dir")
self.pitch_factor = int(config.get("pitch_factor", 0))
self.format = config.get("format", "mp3")
+ self.audio_file_type = config.get("format", "mp3")
self.emotion = int(config.get("emotion", 1))
self.header = {"Content-Type": "application/json"}
@@ -73,9 +74,11 @@ class TTSProvider(TTSProviderBase):
)
audio_content = requests.get(result)
- with open(output_file, "wb") as f:
- f.write(audio_content.content)
- return True
+ if output_file:
+ with open(output_file, "wb") as f:
+ f.write(audio_content.content)
+ else:
+ return audio_content.content
voice_path = resp_json.get("voice_path")
des_path = output_file
shutil.move(voice_path, des_path)
diff --git a/main/xiaozhi-server/core/utils/p3.py b/main/xiaozhi-server/core/utils/p3.py
index a022a2d2..c75b968e 100644
--- a/main/xiaozhi-server/core/utils/p3.py
+++ b/main/xiaozhi-server/core/utils/p3.py
@@ -29,5 +29,31 @@ def decode_opus_from_file(input_file):
total_frames += 1
# 计算总时长
+ total_duration = (total_frames * frame_duration_ms) / 1000.0
+ return opus_datas, total_duration
+
+def decode_opus_from_bytes(input_bytes):
+ """
+ 从p3二进制数据中解码 Opus 数据,并返回一个 Opus 数据包的列表以及总时长。
+ """
+ import io
+ opus_datas = []
+ total_frames = 0
+ sample_rate = 16000 # 文件采样率
+ frame_duration_ms = 60 # 帧时长
+ frame_size = int(sample_rate * frame_duration_ms / 1000)
+
+ f = io.BytesIO(input_bytes)
+ while True:
+ header = f.read(4)
+ if not header:
+ break
+ _, _, data_len = struct.unpack('>BBH', header)
+ opus_data = f.read(data_len)
+ if len(opus_data) != data_len:
+ raise ValueError(f"Data length({len(opus_data)}) mismatch({data_len}) in the bytes.")
+ opus_datas.append(opus_data)
+ total_frames += 1
+
total_duration = (total_frames * frame_duration_ms) / 1000.0
return opus_datas, total_duration
\ No newline at end of file
diff --git a/main/xiaozhi-server/core/utils/util.py b/main/xiaozhi-server/core/utils/util.py
index 82b5d800..b492529c 100644
--- a/main/xiaozhi-server/core/utils/util.py
+++ b/main/xiaozhi-server/core/utils/util.py
@@ -3,6 +3,9 @@ import socket
import subprocess
import re
import os
+import wave
+from io import BytesIO
+from core.utils import p3
import numpy as np
import requests
import opuslib_next
@@ -773,6 +776,22 @@ def audio_to_data(audio_file_path, is_opus=True):
return pcm_to_data(raw_data, is_opus), duration
+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
+ 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)
+ duration = len(audio) / 1000.0
+ raw_data = audio.raw_data
+ return pcm_to_data(raw_data, is_opus), duration
+
+
def pcm_to_data(raw_data, is_opus=True):
# 初始化Opus编码器
encoder = opuslib_next.Encoder(16000, 1, opuslib_next.APPLICATION_AUDIO)
@@ -804,6 +823,33 @@ def pcm_to_data(raw_data, is_opus=True):
return datas
+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)
+
+ 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()
+
+
def check_vad_update(before_config, new_config):
if (
new_config.get("selected_module") is None
From 8df9846ad7b89c4d91528599d3bbe5b9c4922e84 Mon Sep 17 00:00:00 2001
From: CGD <3030332422@qq.com>
Date: Thu, 5 Jun 2025 09:46:54 +0800
Subject: [PATCH 5/9] =?UTF-8?q?update:=E6=94=B9=E4=B8=BA=E9=87=87=E7=94=A8?=
=?UTF-8?q?=E6=97=A5=E5=BF=97=E8=BD=AE=E8=BD=AC=E6=9C=BA=E5=88=B6=EF=BC=8C?=
=?UTF-8?q?=E5=90=8C=E6=97=B6=E6=B7=BB=E5=8A=A0=E8=87=AA=E5=8A=A8=E6=B8=85?=
=?UTF-8?q?=E7=90=86?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
main/xiaozhi-server/config/logger.py | 40 ++++++++++++++++++++++------
1 file changed, 32 insertions(+), 8 deletions(-)
diff --git a/main/xiaozhi-server/config/logger.py b/main/xiaozhi-server/config/logger.py
index 643952dd..7bd22c43 100644
--- a/main/xiaozhi-server/config/logger.py
+++ b/main/xiaozhi-server/config/logger.py
@@ -3,6 +3,7 @@ import sys
from loguru import logger
from config.config_loader import load_config
from config.settings import check_config_file
+from datetime import datetime
SERVER_VERSION = "0.5.4"
_logger_initialized = False
@@ -59,7 +60,7 @@ def setup_logging():
)
log_format_file = log_config.get(
"log_format_file",
- "{time:YYYY-MM-DD HH:mm:ss} - {version_{extra[selected_module]}} - {name} - {level} - {extra[tag]} - {message}",
+ "{time:YYYY-MM-DD HH:mm:ss} - {version}_{extra[selected_module]} - {name} - {level} - {extra[tag]} - {message}",
)
selected_module_str = logger._core.extra["selected_module"]
@@ -84,12 +85,23 @@ def setup_logging():
# 输出到控制台
logger.add(sys.stdout, format=log_format, level=log_level, filter=formatter)
- # 输出到文件
+ # 输出到文件 - 统一目录,按大小轮转
+ # 日志文件完整路径
+ log_file_path = os.path.join(log_dir, log_file)
+
+ # 添加日志处理器
logger.add(
- os.path.join(log_dir, log_file),
+ log_file_path,
format=log_format_file,
level=log_level,
filter=formatter,
+ rotation="10 MB", # 每个文件最大10MB
+ retention="30 days", # 保留30天
+ compression=None,
+ encoding="utf-8",
+ enqueue=True, # 异步安全
+ backtrace=True,
+ diagnose=True,
)
_logger_initialized = True # 标记为已初始化
@@ -116,7 +128,7 @@ def update_module_string(selected_module_str):
)
log_format_file = log_config.get(
"log_format_file",
- "{time:YYYY-MM-DD HH:mm:ss} - {version_{extra[selected_module]}} - {name} - {level} - {extra[tag]} - {message}",
+ "{time:YYYY-MM-DD HH:mm:ss} - {version}_{extra[selected_module]} - {name} - {level} - {extra[tag]} - {message}",
)
log_format = log_format.replace("{version}", SERVER_VERSION)
@@ -133,14 +145,26 @@ def update_module_string(selected_module_str):
level=log_config.get("log_level", "INFO"),
filter=formatter,
)
+
+ # 更新文件日志配置 - 统一目录,按大小轮转
+ log_dir = log_config.get("log_dir", "tmp")
+ log_file = log_config.get("log_file", "server.log")
+
+ # 日志文件完整路径
+ log_file_path = os.path.join(log_dir, log_file)
+
logger.add(
- os.path.join(
- log_config.get("log_dir", "tmp"),
- log_config.get("log_file", "server.log"),
- ),
+ log_file_path,
format=log_format_file,
level=log_config.get("log_level", "INFO"),
filter=formatter,
+ rotation="10 MB", # 每个文件最大10MB
+ retention="30 days", # 保留30天
+ compression=None,
+ encoding="utf-8",
+ enqueue=True, # 异步安全
+ backtrace=True,
+ diagnose=True,
)
except Exception as e:
From dca02c1f4b8c0380673fa2ca8902f261f781d7f6 Mon Sep 17 00:00:00 2001
From: Sakura-RanChen <1908198662@qq.com>
Date: Thu, 5 Jun 2025 14:11:50 +0800
Subject: [PATCH 6/9] =?UTF-8?q?update:=20=E5=94=A4=E9=86=92=E9=9F=B3?=
=?UTF-8?q?=E9=A2=91=E5=85=BC=E5=AE=B9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../xiaozhi-server/core/handle/helloHandle.py | 37 +++++++++++++------
1 file changed, 26 insertions(+), 11 deletions(-)
diff --git a/main/xiaozhi-server/core/handle/helloHandle.py b/main/xiaozhi-server/core/handle/helloHandle.py
index 859059b6..c41baba0 100644
--- a/main/xiaozhi-server/core/handle/helloHandle.py
+++ b/main/xiaozhi-server/core/handle/helloHandle.py
@@ -1,4 +1,5 @@
import os
+import shutil
import time
import json
import random
@@ -119,17 +120,31 @@ async def wakeupWordsResponse(conn):
if result is None or result == "":
return
- opus_datas = await asyncio.to_thread(conn.tts.to_tts, result)
- if not opus_datas:
- return
-
- wav_bytes = opus_datas_to_wav_bytes(opus_datas, sample_rate=16000)
- file_path = os.path.join(
- WAKEUP_CONFIG["dir"], "my_" + WAKEUP_CONFIG["file_name"] + ".wav"
- )
- # 写入wav数据
- with open(file_path, "wb") as f:
- f.write(wav_bytes)
+ tts_result = await asyncio.to_thread(conn.tts.to_tts, result)
+ if conn.tts.delete_audio_file:
+ # 返回的是opus数据流
+ if not tts_result:
+ return
+ wav_bytes = opus_datas_to_wav_bytes(tts_result, sample_rate=16000)
+ file_path = os.path.join(
+ WAKEUP_CONFIG["dir"], "my_" + WAKEUP_CONFIG["file_name"] + ".wav"
+ )
+ with open(file_path, "wb") as f:
+ f.write(wav_bytes)
+ else:
+ # 返回为文件
+ if tts_result is not None and os.path.exists(tts_result):
+ file_type = os.path.splitext(tts_result)[1]
+ if file_type:
+ file_type = file_type.lstrip(".")
+ old_file = getWakeupWordFile("my_" + WAKEUP_CONFIG["file_name"])
+ if old_file is not None:
+ os.remove(old_file)
+ """将文件挪到wakeup_words目录下"""
+ shutil.move(
+ tts_result,
+ WAKEUP_CONFIG["dir"] + "my_" + WAKEUP_CONFIG["file_name"] + '.' + file_type,
+ )
WAKEUP_CONFIG["create_time"] = time.time()
WAKEUP_CONFIG["text"] = result
From 3eb3a4502f7b651b460576feb552c15670dd4b2a Mon Sep 17 00:00:00 2001
From: hrz <1710360675@qq.com>
Date: Fri, 6 Jun 2025 14:08:38 +0800
Subject: [PATCH 7/9] Update fun_local.py
---
main/xiaozhi-server/core/providers/asr/fun_local.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/main/xiaozhi-server/core/providers/asr/fun_local.py b/main/xiaozhi-server/core/providers/asr/fun_local.py
index 4b25a265..e907bdce 100644
--- a/main/xiaozhi-server/core/providers/asr/fun_local.py
+++ b/main/xiaozhi-server/core/providers/asr/fun_local.py
@@ -43,7 +43,7 @@ class ASRProvider(ASRProviderBase):
min_mem_bytes = 2 * 1024 * 1024 * 1024
total_mem = psutil.virtual_memory().total
if total_mem < min_mem_bytes:
- logger.bind(tag=TAG).error(f"可用内存不足2G,当前仅有 {total_mem / (1024*1024):.2f} MB")
+ logger.bind(tag=TAG).error(f"可用内存不足2G,当前仅有 {total_mem / (1024*1024):.2f} MB,可能无法启动FunASR")
self.interface_type = InterfaceType.LOCAL
self.model_dir = config.get("model_dir")
From 50e6e4817b74cf40fa7c9aa6ec294ab79cf24c1c Mon Sep 17 00:00:00 2001
From: hrz <1710360675@qq.com>
Date: Sat, 7 Jun 2025 22:08:22 +0800
Subject: [PATCH 8/9] =?UTF-8?q?update:=E4=BC=98=E5=8C=96=E5=94=A4=E9=86=92?=
=?UTF-8?q?=E8=AF=8D=E7=AD=94=E5=A4=8D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../xiaozhi-server/core/handle/helloHandle.py | 150 ++++++++----------
.../providers/tts/huoshan_double_stream.py | 9 +-
.../core/providers/tts/minimax.py | 8 +-
.../core/providers/tts/ttson.py | 6 +-
main/xiaozhi-server/core/utils/wakeup_word.py | 143 +++++++++++++++++
5 files changed, 221 insertions(+), 95 deletions(-)
create mode 100644 main/xiaozhi-server/core/utils/wakeup_word.py
diff --git a/main/xiaozhi-server/core/handle/helloHandle.py b/main/xiaozhi-server/core/handle/helloHandle.py
index c41baba0..154a2096 100644
--- a/main/xiaozhi-server/core/handle/helloHandle.py
+++ b/main/xiaozhi-server/core/handle/helloHandle.py
@@ -12,19 +12,21 @@ from core.handle.mcpHandle import (
send_mcp_initialize_message,
send_mcp_tools_list_request,
)
-
+from core.utils.wakeup_word import WakeupWordsConfig
TAG = __name__
WAKEUP_CONFIG = {
- "dir": "config/assets/",
- "file_name": "wakeup_words",
- "create_time": time.time(),
- "refresh_time": 10,
+ "refresh_time": 5,
"words": ["你好小智", "你好啊小智", "小智你好", "小智"],
- "text": "",
}
+# 创建全局的唤醒词配置管理器
+wakeup_words_config = WakeupWordsConfig()
+
+# 用于防止并发调用wakeupWordsResponse的锁
+_wakeup_response_lock = asyncio.Lock()
+
async def handleHelloMessage(conn, msg_json):
"""处理hello消息"""
@@ -55,96 +57,78 @@ async def checkWakeupWords(conn, text):
enable_wakeup_words_response_cache = conn.config[
"enable_wakeup_words_response_cache"
]
- """是否用的是非流式tts"""
- if conn.tts and conn.tts.interface_type != InterfaceType.NON_STREAM:
+
+ if not enable_wakeup_words_response_cache or not conn.tts:
return False
- """是否开启唤醒词加速"""
- if not enable_wakeup_words_response_cache:
+ if conn.tts.interface_type != InterfaceType.NON_STREAM:
return False
- """检查是否是唤醒词"""
+
_, filtered_text = remove_punctuation_and_length(text)
- if filtered_text in conn.config.get("wakeup_words"):
- await send_stt_message(conn, text)
+ if filtered_text not in conn.config.get("wakeup_words"):
+ return False
- file = getWakeupWordFile(WAKEUP_CONFIG["file_name"])
- if file is None:
+ await send_stt_message(conn, text)
+
+ # 获取当前音色
+ voice = getattr(conn.tts, "voice", "default")
+
+ # 获取唤醒词回复配置
+ response = wakeup_words_config.get_wakeup_response(voice)
+
+ # 播放唤醒词回复
+ conn.client_abort = False
+ conn.tts.tts_one_sentence(
+ conn,
+ ContentType.FILE,
+ content_file=response["file_path"],
+ content_detail=response["text"],
+ )
+
+ # 检查是否需要更新唤醒词回复
+ if time.time() - response["time"] > WAKEUP_CONFIG["refresh_time"]:
+ if not _wakeup_response_lock.locked():
asyncio.create_task(wakeupWordsResponse(conn))
- return False
- text_hello = WAKEUP_CONFIG["text"]
- if not text_hello:
- text_hello = text
- if conn.tts is None:
- return False
- conn.tts.tts_one_sentence(
- conn, ContentType.FILE, content_file=file, content_detail=text_hello
- )
- if time.time() - WAKEUP_CONFIG["create_time"] > WAKEUP_CONFIG["refresh_time"]:
- asyncio.create_task(wakeupWordsResponse(conn))
- return True
- return False
-
-
-def getWakeupWordFile(file_name):
- for file in os.listdir(WAKEUP_CONFIG["dir"]):
- if file.startswith("my_" + file_name):
- """避免缓存文件是一个空文件"""
- if os.stat(f"config/assets/{file}").st_size > (15 * 1024):
- return f"config/assets/{file}"
-
- """查找config/assets/目录下名称为wakeup_words的文件"""
- for file in os.listdir(WAKEUP_CONFIG["dir"]):
- if file.startswith(file_name):
- return f"config/assets/{file}"
- return None
+ return True
async def wakeupWordsResponse(conn):
- wait_max_time = 5
- while conn.llm is None or not conn.llm.response_no_stream:
- await asyncio.sleep(1)
- wait_max_time -= 1
- if wait_max_time <= 0:
- conn.logger.bind(tag=TAG).error("连接对象没有llm")
- return
-
- """唤醒词响应"""
- wakeup_word = random.choice(WAKEUP_CONFIG["words"])
- question = (
- "此刻用户正在和你说```"
- + wakeup_word
- + "```。\n请你根据以上用户的内容,进行简短回复,文字内容控制在15个字以内。\n"
- + "请勿对这条内容本身进行任何解释和回应,仅返回对用户的内容的回复。"
- )
- result = conn.llm.response_no_stream(conn.config["prompt"], question)
- if result is None or result == "":
+ if not conn.tts or not conn.llm or not conn.llm.response_no_stream:
return
- tts_result = await asyncio.to_thread(conn.tts.to_tts, result)
- if conn.tts.delete_audio_file:
- # 返回的是opus数据流
+ try:
+ # 尝试获取锁,如果获取不到就返回
+ if not await _wakeup_response_lock.acquire():
+ return
+
+ # 生成唤醒词回复
+ wakeup_word = random.choice(WAKEUP_CONFIG["words"])
+ question = (
+ "此刻用户正在和你说```"
+ + wakeup_word
+ + "```。\n请你根据以上用户的内容进行简短回复。要像一个人正常人一样说话,不要像机器人一样说话。\n"
+ + "请勿对这条内容本身进行任何解释和回应,请勿返回表情符号,仅返回对用户的内容的回复。"
+ )
+
+ result = conn.llm.response_no_stream(conn.config["prompt"], question)
+ if not result or len(result) == 0:
+ return
+
+ # 生成TTS音频
+ tts_result = await asyncio.to_thread(conn.tts.to_tts, result)
if not tts_result:
return
+
+ # 获取当前音色
+ voice = getattr(conn.tts, "voice", "default")
+
wav_bytes = opus_datas_to_wav_bytes(tts_result, sample_rate=16000)
- file_path = os.path.join(
- WAKEUP_CONFIG["dir"], "my_" + WAKEUP_CONFIG["file_name"] + ".wav"
- )
+ file_path = wakeup_words_config.generate_file_path(voice)
with open(file_path, "wb") as f:
f.write(wav_bytes)
-
- else:
- # 返回为文件
- if tts_result is not None and os.path.exists(tts_result):
- file_type = os.path.splitext(tts_result)[1]
- if file_type:
- file_type = file_type.lstrip(".")
- old_file = getWakeupWordFile("my_" + WAKEUP_CONFIG["file_name"])
- if old_file is not None:
- os.remove(old_file)
- """将文件挪到wakeup_words目录下"""
- shutil.move(
- tts_result,
- WAKEUP_CONFIG["dir"] + "my_" + WAKEUP_CONFIG["file_name"] + '.' + file_type,
- )
- WAKEUP_CONFIG["create_time"] = time.time()
- WAKEUP_CONFIG["text"] = result
+ # 更新配置
+ wakeup_words_config.update_wakeup_response(voice, file_path, result)
+ finally:
+ # 确保在任何情况下都释放锁
+ if _wakeup_response_lock.locked():
+ _wakeup_response_lock.release()
diff --git a/main/xiaozhi-server/core/providers/tts/huoshan_double_stream.py b/main/xiaozhi-server/core/providers/tts/huoshan_double_stream.py
index 4d15204e..5193e19e 100644
--- a/main/xiaozhi-server/core/providers/tts/huoshan_double_stream.py
+++ b/main/xiaozhi-server/core/providers/tts/huoshan_double_stream.py
@@ -145,10 +145,9 @@ class TTSProvider(TTSProviderBase):
self.cluster = config.get("cluster")
self.resource_id = config.get("resource_id")
if config.get("private_voice"):
- self.speaker = config.get("private_voice")
+ self.voice = config.get("private_voice")
else:
- self.speaker = config.get("speaker")
- self.voice = config.get("voice")
+ self.voice = config.get("speaker")
self.ws_url = config.get("ws_url")
self.authorization = config.get("authorization")
self.header = {"Authorization": f"{self.authorization}{self.access_token}"}
@@ -272,7 +271,7 @@ class TTSProvider(TTSProviderBase):
logger.bind(tag=TAG).error(f"WebSocket连接不存在,终止发送文本")
return
# 发送文本
- await self.send_text(self.speaker, text, self.conn.sentence_id)
+ await self.send_text(self.voice, text, self.conn.sentence_id)
return
except Exception as e:
logger.bind(tag=TAG).error(f"发送TTS文本失败: {str(e)}")
@@ -302,7 +301,7 @@ class TTSProvider(TTSProviderBase):
event=EVENT_StartSession, sessionId=session_id
).as_bytes()
payload = self.get_payload_bytes(
- event=EVENT_StartSession, speaker=self.speaker
+ event=EVENT_StartSession, speaker=self.voice
)
await self.send_event(header, optional, payload)
logger.bind(tag=TAG).info("会话启动请求已发送")
diff --git a/main/xiaozhi-server/core/providers/tts/minimax.py b/main/xiaozhi-server/core/providers/tts/minimax.py
index 3741d169..75e25e2e 100644
--- a/main/xiaozhi-server/core/providers/tts/minimax.py
+++ b/main/xiaozhi-server/core/providers/tts/minimax.py
@@ -14,9 +14,9 @@ class TTSProvider(TTSProviderBase):
self.api_key = config.get("api_key")
self.model = config.get("model")
if config.get("private_voice"):
- self.voice_id = config.get("private_voice")
+ self.voice = config.get("private_voice")
else:
- self.voice_id = config.get("voice_id")
+ self.voice = config.get("voice_id")
default_voice_setting = {
"voice_id": "female-shaonv",
@@ -43,8 +43,8 @@ class TTSProvider(TTSProviderBase):
self.audio_setting = {**defult_audio_setting, **config.get("audio_setting", {})}
self.timber_weights = parse_string_to_list(config.get("timber_weights"))
- if self.voice_id:
- self.voice_setting["voice_id"] = self.voice_id
+ if self.voice:
+ self.voice_setting["voice_id"] = self.voice
self.host = "api.minimax.chat"
self.api_url = f"https://{self.host}/v1/t2a_v2?GroupId={self.group_id}"
diff --git a/main/xiaozhi-server/core/providers/tts/ttson.py b/main/xiaozhi-server/core/providers/tts/ttson.py
index c11206a6..91deeed9 100644
--- a/main/xiaozhi-server/core/providers/tts/ttson.py
+++ b/main/xiaozhi-server/core/providers/tts/ttson.py
@@ -19,9 +19,9 @@ class TTSProvider(TTSProviderBase):
"https://u95167-bd74-2aef8085.westx.seetacloud.com:8443/flashsummary/tts?token=",
)
if config.get("private_voice"):
- self.voice_id = int(config.get("private_voice"))
+ self.voice = int(config.get("private_voice"))
else:
- self.voice_id = int(config.get("voice_id", 1695))
+ self.voice = int(config.get("voice_id", 1695))
self.token = config.get("token")
self.to_lang = config.get("to_lang")
self.volume_change_dB = int(config.get("volume_change_dB", 0))
@@ -50,7 +50,7 @@ class TTSProvider(TTSProviderBase):
"emotion": self.emotion,
"format": self.format,
"volume_change_dB": self.volume_change_dB,
- "voice_id": self.voice_id,
+ "voice_id": self.voice,
"pitch_factor": self.pitch_factor,
"speed_factor": self.speed_factor,
"token": self.token,
diff --git a/main/xiaozhi-server/core/utils/wakeup_word.py b/main/xiaozhi-server/core/utils/wakeup_word.py
new file mode 100644
index 00000000..258f879b
--- /dev/null
+++ b/main/xiaozhi-server/core/utils/wakeup_word.py
@@ -0,0 +1,143 @@
+import os
+import yaml
+import time
+import hashlib
+import fcntl
+from typing import Dict
+from contextlib import contextmanager
+
+
+class FileLock:
+ def __init__(self, file, timeout=5):
+ self.file = file
+ self.timeout = timeout
+ self.start_time = None
+
+ def __enter__(self):
+ self.start_time = time.time()
+ while True:
+ try:
+ fcntl.flock(self.file, fcntl.LOCK_EX | fcntl.LOCK_NB)
+ return self.file
+ except IOError:
+ if time.time() - self.start_time > self.timeout:
+ raise TimeoutError("获取文件锁超时")
+ time.sleep(0.1)
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ fcntl.flock(self.file, fcntl.LOCK_UN)
+
+
+class WakeupWordsConfig:
+ def __init__(self):
+ self.config_file = "data/.wakeup_words.yaml"
+ self.assets_dir = "config/assets/wakeup_words"
+ self._ensure_directories()
+ self._config_cache = None
+ self._last_load_time = 0
+ self._cache_ttl = 1 # 缓存有效期(秒)
+ self._lock_timeout = 5 # 文件锁超时时间(秒)
+
+ def _ensure_directories(self):
+ """确保必要的目录存在"""
+ os.makedirs(os.path.dirname(self.config_file), exist_ok=True)
+ os.makedirs(self.assets_dir, exist_ok=True)
+
+ def _load_config(self) -> Dict:
+ """加载配置文件,使用缓存机制"""
+ current_time = time.time()
+
+ # 如果缓存有效,直接返回缓存
+ if (
+ self._config_cache is not None
+ and current_time - self._last_load_time < self._cache_ttl
+ ):
+ return self._config_cache
+
+ try:
+ with open(self.config_file, "a+") as f:
+ with FileLock(f, timeout=self._lock_timeout):
+ f.seek(0)
+ content = f.read()
+ config = yaml.safe_load(content) if content else {}
+ self._config_cache = config
+ self._last_load_time = current_time
+ return config
+ except (TimeoutError, IOError) as e:
+ print(f"加载配置文件失败: {e}")
+ return {}
+ except Exception as e:
+ print(f"加载配置文件时发生未知错误: {e}")
+ return {}
+
+ def _save_config(self, config: Dict):
+ """保存配置到文件,使用文件锁保护"""
+ try:
+ with open(self.config_file, "w") as f:
+ with FileLock(f, timeout=self._lock_timeout):
+ yaml.dump(config, f, allow_unicode=True)
+ self._config_cache = config
+ self._last_load_time = time.time()
+ except (TimeoutError, IOError) as e:
+ print(f"保存配置文件失败: {e}")
+ raise
+ except Exception as e:
+ print(f"保存配置文件时发生未知错误: {e}")
+ raise
+
+ def get_wakeup_response(self, voice: str) -> Dict:
+ voice = hashlib.md5(voice.encode()).hexdigest()
+ """获取唤醒词回复配置"""
+ config = self._load_config()
+ default_response = {
+ "voice": "default",
+ "file_path": "config/assets/wakeup_words.wav",
+ "time": 0,
+ "text": "哈啰啊,我是小智啦,声音好听的台湾女孩一枚,超开心认识你耶,最近在忙啥,别忘了给我来点有趣的料哦,我超爱听八卦的啦",
+ }
+
+ if not config or voice not in config:
+ return default_response
+
+ # 检查文件大小
+ file_path = config[voice]["file_path"]
+ if not os.path.exists(file_path) or os.stat(file_path).st_size < (15 * 1024):
+ return default_response
+
+ return config[voice]
+
+ def update_wakeup_response(self, voice: str, file_path: str, text: str):
+ """更新唤醒词回复配置"""
+ try:
+ config = self._load_config()
+ voice_hash = hashlib.md5(voice.encode()).hexdigest()
+ config[voice_hash] = {
+ "voice": voice,
+ "file_path": file_path,
+ "time": time.time(),
+ "text": text,
+ }
+ self._save_config(config)
+ except Exception as e:
+ print(f"更新唤醒词回复配置失败: {e}")
+ raise
+
+ def generate_file_path(self, voice: str) -> str:
+ """生成音频文件路径,使用voice的哈希值作为文件名"""
+ try:
+ # 生成voice的哈希值
+ voice_hash = hashlib.md5(voice.encode()).hexdigest()
+ file_path = os.path.join(self.assets_dir, f"{voice_hash}.wav")
+
+ # 如果文件已存在,先删除
+ if os.path.exists(file_path):
+ try:
+ os.remove(file_path)
+ except Exception as e:
+ print(f"删除已存在的音频文件失败: {e}")
+ raise
+
+ return file_path
+ except Exception as e:
+ print(f"生成音频文件路径失败: {e}")
+ raise
From 98174bcc162128a9e9258d077f47a3e122812a8a Mon Sep 17 00:00:00 2001
From: hrz <1710360675@qq.com>
Date: Sun, 8 Jun 2025 00:06:40 +0800
Subject: [PATCH 9/9] =?UTF-8?q?update:=E3=80=90=E6=B5=81=E5=BC=8Ftts?=
=?UTF-8?q?=E3=80=91=E5=A2=9E=E5=8A=A0=E3=80=90=E9=9D=9E=E6=B5=81=E5=BC=8F?=
=?UTF-8?q?=E3=80=91=E6=96=B9=E6=B3=95=EF=BC=8C=E7=94=A8=E4=BA=8E=E6=B5=8B?=
=?UTF-8?q?=E8=AF=95=E5=8F=8A=E7=94=9F=E6=88=90=E6=96=87=E4=BB=B6=E7=9A=84?=
=?UTF-8?q?=E5=9C=BA=E6=99=AF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../xiaozhi-server/core/handle/helloHandle.py | 17 +--
.../providers/tts/huoshan_double_stream.py | 120 +++++++++++++++++-
.../core/providers/tts/linkerai.py | 78 +++++++++++-
3 files changed, 194 insertions(+), 21 deletions(-)
diff --git a/main/xiaozhi-server/core/handle/helloHandle.py b/main/xiaozhi-server/core/handle/helloHandle.py
index b19dc054..bb15382d 100644
--- a/main/xiaozhi-server/core/handle/helloHandle.py
+++ b/main/xiaozhi-server/core/handle/helloHandle.py
@@ -1,12 +1,11 @@
-import os
-import shutil
import time
import json
import random
import asyncio
+from core.utils.util import audio_to_data
from core.handle.sendAudioHandle import send_stt_message
from core.utils.util import remove_punctuation_and_length, opus_datas_to_wav_bytes
-from core.providers.tts.dto.dto import ContentType, InterfaceType
+from core.providers.tts.dto.dto import ContentType, SentenceType
from core.handle.mcpHandle import (
MCPClient,
send_mcp_initialize_message,
@@ -59,9 +58,6 @@ async def checkWakeupWords(conn, text):
if not enable_wakeup_words_response_cache or not conn.tts:
return False
- if conn.tts.interface_type != InterfaceType.NON_STREAM:
- return False
-
_, filtered_text = remove_punctuation_and_length(text)
if filtered_text not in conn.config.get("wakeup_words"):
return False
@@ -77,12 +73,9 @@ async def checkWakeupWords(conn, text):
# 播放唤醒词回复
conn.client_abort = False
- conn.tts.tts_one_sentence(
- conn,
- ContentType.FILE,
- content_file=response["file_path"],
- content_detail=response["text"],
- )
+ opus_packets, _ = audio_to_data(response["file_path"])
+ conn.tts.tts_audio_queue.put((SentenceType.FIRST, opus_packets, response["text"]))
+ conn.tts.tts_audio_queue.put((SentenceType.LAST, [], None))
# 检查是否需要更新唤醒词回复
if time.time() - response["time"] > WAKEUP_CONFIG["refresh_time"]:
diff --git a/main/xiaozhi-server/core/providers/tts/huoshan_double_stream.py b/main/xiaozhi-server/core/providers/tts/huoshan_double_stream.py
index 10a37b51..33a1c8ec 100644
--- a/main/xiaozhi-server/core/providers/tts/huoshan_double_stream.py
+++ b/main/xiaozhi-server/core/providers/tts/huoshan_double_stream.py
@@ -301,7 +301,7 @@ class TTSProvider(TTSProviderBase):
payload = self.get_payload_bytes(
event=EVENT_StartSession, speaker=self.voice
)
- await self.send_event(header, optional, payload)
+ await self.send_event(self.ws, header, optional, payload)
logger.bind(tag=TAG).info("会话启动请求已发送")
except Exception as e:
logger.bind(tag=TAG).error(f"启动会话失败: {str(e)}")
@@ -334,7 +334,7 @@ class TTSProvider(TTSProviderBase):
event=EVENT_FinishSession, sessionId=session_id
).as_bytes()
payload = str.encode("{}")
- await self.send_event(header, optional, payload)
+ await self.send_event(self.ws, header, optional, payload)
logger.bind(tag=TAG).info("会话结束请求已发送")
# 等待监听任务完成
@@ -451,7 +451,11 @@ class TTSProvider(TTSProviderBase):
self.ws = None
async def send_event(
- self, header: bytes, optional: bytes | None = None, payload: bytes = None
+ self,
+ ws: websockets.WebSocketClientProtocol,
+ header: bytes,
+ optional: bytes | None = None,
+ payload: bytes = None,
):
try:
full_client_request = bytearray(header)
@@ -461,7 +465,7 @@ class TTSProvider(TTSProviderBase):
payload_size = len(payload).to_bytes(4, "big", signed=True)
full_client_request.extend(payload_size)
full_client_request.extend(payload)
- await self.ws.send(full_client_request)
+ await ws.send(full_client_request)
except websockets.ConnectionClosed:
logger.bind(tag=TAG).error(f"ConnectionClosed")
raise
@@ -476,7 +480,7 @@ class TTSProvider(TTSProviderBase):
payload = self.get_payload_bytes(
event=EVENT_TaskRequest, text=text, speaker=speaker
)
- return await self.send_event(header, optional, payload)
+ return await self.send_event(self.ws, header, optional, payload)
# 读取 res 数组某段 字符串内容
def read_res_content(self, res: bytes, offset: int):
@@ -554,7 +558,7 @@ class TTSProvider(TTSProviderBase):
).as_bytes()
optional = Optional(event=EVENT_Start_Connection).as_bytes()
payload = str.encode("{}")
- return await self.send_event(header, optional, payload)
+ return await self.send_event(self.ws, header, optional, payload)
def print_response(self, res, tag_msg: str):
logger.bind(tag=TAG).debug(f"===>{tag_msg} header:{res.header.__dict__}")
@@ -590,3 +594,107 @@ class TTSProvider(TTSProviderBase):
def wav_to_opus_data_audio_raw(self, raw_data_var, is_end=False):
opus_datas = self.opus_encoder.encode_pcm_to_opus(raw_data_var, is_end)
return opus_datas
+
+ def to_tts(self, text: str) -> list:
+ """非流式生成音频数据,用于生成音频及测试场景
+
+ Args:
+ text: 要转换的文本
+
+ Returns:
+ list: 音频数据列表
+ """
+ try:
+ # 创建事件循环
+ loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(loop)
+
+ # 生成会话ID
+ session_id = uuid.uuid4().__str__().replace("-", "")
+
+ # 存储音频数据
+ audio_data = []
+
+ async def _generate_audio():
+ # 创建新的WebSocket连接
+ ws_header = {
+ "X-Api-App-Key": self.appId,
+ "X-Api-Access-Key": self.access_token,
+ "X-Api-Resource-Id": self.resource_id,
+ "X-Api-Connect-Id": uuid.uuid4(),
+ }
+ ws = await websockets.connect(
+ self.ws_url, additional_headers=ws_header, max_size=1000000000
+ )
+
+ try:
+ # 启动会话
+ header = Header(
+ message_type=FULL_CLIENT_REQUEST,
+ message_type_specific_flags=MsgTypeFlagWithEvent,
+ serial_method=JSON,
+ ).as_bytes()
+ optional = Optional(
+ event=EVENT_StartSession, sessionId=session_id
+ ).as_bytes()
+ payload = self.get_payload_bytes(
+ event=EVENT_StartSession, speaker=self.voice
+ )
+ await self.send_event(ws, header, optional, payload)
+
+ # 发送文本
+ header = Header(
+ message_type=FULL_CLIENT_REQUEST,
+ message_type_specific_flags=MsgTypeFlagWithEvent,
+ serial_method=JSON,
+ ).as_bytes()
+ optional = Optional(
+ event=EVENT_TaskRequest, sessionId=session_id
+ ).as_bytes()
+ payload = self.get_payload_bytes(
+ event=EVENT_TaskRequest, text=text, speaker=self.voice
+ )
+ await self.send_event(ws, header, optional, payload)
+
+ # 发送结束会话请求
+ header = Header(
+ message_type=FULL_CLIENT_REQUEST,
+ message_type_specific_flags=MsgTypeFlagWithEvent,
+ serial_method=JSON,
+ ).as_bytes()
+ optional = Optional(
+ event=EVENT_FinishSession, sessionId=session_id
+ ).as_bytes()
+ payload = str.encode("{}")
+ await self.send_event(ws, header, optional, payload)
+
+ # 接收音频数据
+ while True:
+ msg = await ws.recv()
+ res = self.parser_response(msg)
+
+ if (
+ res.optional.event == EVENT_TTSResponse
+ and res.header.message_type == AUDIO_ONLY_RESPONSE
+ ):
+ opus_datas = self.wav_to_opus_data_audio_raw(res.payload)
+ audio_data.extend(opus_datas)
+ elif res.optional.event == EVENT_SessionFinished:
+ break
+
+ finally:
+ # 清理资源
+ try:
+ await ws.close()
+ except:
+ pass
+
+ # 运行异步任务
+ loop.run_until_complete(_generate_audio())
+ loop.close()
+
+ return audio_data
+
+ except Exception as e:
+ logger.bind(tag=TAG).error(f"生成音频数据失败: {str(e)}")
+ return []
diff --git a/main/xiaozhi-server/core/providers/tts/linkerai.py b/main/xiaozhi-server/core/providers/tts/linkerai.py
index dd8d0e6b..649ed51d 100644
--- a/main/xiaozhi-server/core/providers/tts/linkerai.py
+++ b/main/xiaozhi-server/core/providers/tts/linkerai.py
@@ -2,6 +2,8 @@ import queue
import asyncio
import traceback
import aiohttp
+import requests
+import time
from config.logger import setup_logging
from core.utils.tts import MarkdownCleaner
from core.providers.tts.base import TTSProviderBase
@@ -55,7 +57,7 @@ class TTSProvider(TTSProviderBase):
self.tts_text_buff.append(message.content_detail)
segment_text = self._get_segment_text()
if segment_text:
- self.to_tts(segment_text)
+ self.to_tts_single_stream(segment_text)
elif ContentType.FILE == message.content_type:
logger.bind(tag=TAG).info(
@@ -87,12 +89,12 @@ class TTSProvider(TTSProviderBase):
if remaining_text:
segment_text = textUtils.get_string_no_punctuation_or_emoji(remaining_text)
if segment_text:
- self.to_tts(segment_text, is_last)
+ self.to_tts_single_stream(segment_text, is_last)
self.processed_chars += len(full_text)
else:
self._process_before_stop_play_files()
- def to_tts(self, text, is_last=False):
+ def to_tts_single_stream(self, text, is_last=False):
try:
max_repeat_time = 5
text = MarkdownCleaner.clean_markdown(text)
@@ -225,3 +227,73 @@ class TTSProvider(TTSProviderBase):
except Exception as e:
logger.error(f"TTS请求异常: {e}")
self.tts_audio_queue.put((SentenceType.LAST, [], None))
+
+ def to_tts(self, text: str) -> list:
+ """非流式TTS处理,用于测试及保存音频文件的场景
+
+ Args:
+ text: 要转换的文本
+
+ Returns:
+ list: 返回opus编码后的音频数据列表
+ """
+ start_time = time.time()
+ text = MarkdownCleaner.clean_markdown(text)
+
+ params = {
+ "tts_text": text,
+ "spk_id": self.voice,
+ "frame_duration": 60,
+ "stream": False,
+ "target_sr": 16000,
+ "audio_format": self.audio_format,
+ "instruct_text": "请生成一段自然流畅的语音",
+ }
+ headers = {
+ "Authorization": f"Bearer {self.access_token}",
+ "Content-Type": "application/json",
+ }
+
+ try:
+ with requests.get(
+ self.api_url, params=params, headers=headers, timeout=5
+ ) as response:
+ if response.status_code != 200:
+ logger.error(
+ f"TTS请求失败: {response.status_code}, {response.text}"
+ )
+ return []
+
+ logger.info(f"TTS请求成功: {text}, 耗时: {time.time() - start_time}秒")
+
+ # 使用opus编码器处理PCM数据
+ opus_datas = []
+ pcm_data = response.content
+
+ # 计算每帧的字节数
+ frame_bytes = int(
+ self.opus_encoder.sample_rate
+ * self.opus_encoder.channels
+ * self.opus_encoder.frame_size_ms
+ / 1000
+ * 2
+ )
+
+ # 分帧处理PCM数据
+ for i in range(0, len(pcm_data), frame_bytes):
+ frame = pcm_data[i : i + frame_bytes]
+ if len(frame) < frame_bytes:
+ # 最后一帧可能不足,用0填充
+ frame = frame + b"\x00" * (frame_bytes - len(frame))
+
+ opus = self.opus_encoder.encode_pcm_to_opus(
+ frame, end_of_stream=(i + frame_bytes >= len(pcm_data))
+ )
+ if opus:
+ opus_datas.extend(opus)
+
+ return opus_datas
+
+ except Exception as e:
+ logger.error(f"TTS请求异常: {e}")
+ return []