mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-29 01:33:51 +08:00
Merge branch 'main' into py_wakeup_audio
This commit is contained in:
@@ -94,7 +94,7 @@ Spearheaded by Professor Siyuan Liu's Team (South China University of Technology
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<a href="https://www.bilibili.com/video/BV1Vy96YCE3R" target="_blank">
|
<a href="https://www.bilibili.com/video/BV1vchQzaEse" target="_blank">
|
||||||
<picture>
|
<picture>
|
||||||
<img alt="自定义音色" src="docs/images/demo6.png" />
|
<img alt="自定义音色" src="docs/images/demo6.png" />
|
||||||
</picture>
|
</picture>
|
||||||
|
|||||||
+1
-1
@@ -93,7 +93,7 @@ Want to see the usage effects? Click the videos below 🎥
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<a href="https://www.bilibili.com/video/BV1Vy96YCE3R" target="_blank">
|
<a href="https://www.bilibili.com/video/BV1vchQzaEse" target="_blank">
|
||||||
<picture>
|
<picture>
|
||||||
<img alt="Custom voice timbre" src="docs/images/demo6.png" />
|
<img alt="Custom voice timbre" src="docs/images/demo6.png" />
|
||||||
</picture>
|
</picture>
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 306 KiB After Width: | Height: | Size: 111 KiB |
@@ -75,7 +75,7 @@ TTS:
|
|||||||
sample_rate: 24000 # 采样率 [websocket默认24000,http默认0 自动选择]
|
sample_rate: 24000 # 采样率 [websocket默认24000,http默认0 自动选择]
|
||||||
speed: 1.0 # 语速,1.0 表示正常语速,>1 表示加快,<1 表示减慢
|
speed: 1.0 # 语速,1.0 表示正常语速,>1 表示加快,<1 表示减慢
|
||||||
volume: 1.0 # 音量,1.0 表示正常音量,>1 表示增大,<1 表示减小
|
volume: 1.0 # 音量,1.0 表示正常音量,>1 表示增大,<1 表示减小
|
||||||
save_path: ./streaming_tts.wav # 服务器生成的语音文件保存路径
|
save_path: # 保存路径
|
||||||
```
|
```
|
||||||
### 3.启动xiaozhi服务
|
### 3.启动xiaozhi服务
|
||||||
```py
|
```py
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
class="center-dialog" >
|
class="center-dialog" >
|
||||||
<div style="margin: 0 18px; text-align: left; padding: 10px; border-radius: 10px;">
|
<div style="margin: 0 18px; text-align: left; padding: 10px; border-radius: 10px;">
|
||||||
<div style="font-size: 30px; color: #3d4566; margin-top: -10px; margin-bottom: 10px; text-align: center;">
|
<div style="font-size: 30px; color: #3d4566; margin-top: -10px; margin-bottom: 10px; text-align: center;">
|
||||||
修改模型
|
{{ modelData.duplicateMode ? '创建副本' : '修改模型' }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="custom-close-btn" @click="dialogVisible = false">
|
<button class="custom-close-btn" @click="dialogVisible = false">
|
||||||
@@ -190,6 +190,11 @@ export default {
|
|||||||
Api.model.getModelConfig(this.modelData.id, ({ data }) => {
|
Api.model.getModelConfig(this.modelData.id, ({ data }) => {
|
||||||
if (data.code === 0 && data.data) {
|
if (data.code === 0 && data.data) {
|
||||||
const model = data.data;
|
const model = data.data;
|
||||||
|
|
||||||
|
if (this.modelData.duplicateMode) {
|
||||||
|
model.modelName = this.modelData.modelName + '_副本';
|
||||||
|
model.modelCode = this.modelData.modelCode + '_副本';
|
||||||
|
}
|
||||||
this.pendingProviderType = model.configJson.type;
|
this.pendingProviderType = model.configJson.type;
|
||||||
this.pendingModelData = model;
|
this.pendingModelData = model;
|
||||||
|
|
||||||
|
|||||||
@@ -79,11 +79,14 @@
|
|||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" align="center" width="150px">
|
<el-table-column label="操作" align="center" width="180px">
|
||||||
<template slot-scope="scope">
|
<template slot-scope="scope">
|
||||||
<el-button type="text" size="mini" @click="editModel(scope.row)" class="edit-btn">
|
<el-button type="text" size="mini" @click="editModel(scope.row)" class="edit-btn">
|
||||||
修改
|
修改
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<el-button type="text" size="mini" @click="duplicateModel(scope.row)" class="edit-btn">
|
||||||
|
创建副本
|
||||||
|
</el-button>
|
||||||
<el-button type="text" size="mini" @click="deleteModel(scope.row)" class="delete-btn">
|
<el-button type="text" size="mini" @click="deleteModel(scope.row)" class="delete-btn">
|
||||||
删除
|
删除
|
||||||
</el-button>
|
</el-button>
|
||||||
@@ -277,6 +280,11 @@ export default {
|
|||||||
this.editModelData = JSON.parse(JSON.stringify(model));
|
this.editModelData = JSON.parse(JSON.stringify(model));
|
||||||
this.editDialogVisible = true;
|
this.editDialogVisible = true;
|
||||||
},
|
},
|
||||||
|
duplicateModel(model) {
|
||||||
|
this.editModelData = JSON.parse(JSON.stringify(model));
|
||||||
|
this.editModelData.duplicateMode = true;
|
||||||
|
this.editDialogVisible = true;
|
||||||
|
},
|
||||||
// 删除单个模型
|
// 删除单个模型
|
||||||
deleteModel(model) {
|
deleteModel(model) {
|
||||||
this.$confirm('确定要删除该模型吗?', '提示', {
|
this.$confirm('确定要删除该模型吗?', '提示', {
|
||||||
@@ -313,19 +321,34 @@ export default {
|
|||||||
const modelType = this.activeTab;
|
const modelType = this.activeTab;
|
||||||
const id = formData.id;
|
const id = formData.id;
|
||||||
|
|
||||||
Api.model.updateModel(
|
if (this.editModelData.duplicateMode) {
|
||||||
{ modelType, provideCode, id, formData },
|
Api.model.addModel({modelType, provideCode, formData},
|
||||||
({ data }) => {
|
({ data }) => {
|
||||||
if (data.code === 0) {
|
if (data.code === 0) {
|
||||||
this.$message.success('保存成功');
|
this.$message.success('创建副本成功');
|
||||||
this.loadData();
|
this.loadData();
|
||||||
this.editDialogVisible = false;
|
this.editDialogVisible = false;
|
||||||
} else {
|
} else {
|
||||||
this.$message.error(data.msg || '保存失败');
|
this.$message.error(data.msg || '创建副本失败');
|
||||||
}
|
}
|
||||||
done && done(); // 调用done回调关闭加载状态
|
done && done(); // 调用done回调关闭加载状态
|
||||||
}
|
})
|
||||||
);
|
}
|
||||||
|
else {
|
||||||
|
Api.model.updateModel(
|
||||||
|
{ modelType, provideCode, id, formData },
|
||||||
|
({ data }) => {
|
||||||
|
if (data.code === 0) {
|
||||||
|
this.$message.success('保存成功');
|
||||||
|
this.loadData();
|
||||||
|
this.editDialogVisible = false;
|
||||||
|
} else {
|
||||||
|
this.$message.error(data.msg || '保存失败');
|
||||||
|
}
|
||||||
|
done && done(); // 调用done回调关闭加载状态
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
selectAll() {
|
selectAll() {
|
||||||
if (this.isAllSelected) {
|
if (this.isAllSelected) {
|
||||||
|
|||||||
@@ -59,8 +59,6 @@ log:
|
|||||||
delete_audio: true
|
delete_audio: true
|
||||||
# 没有语音输入多久后断开连接(秒),默认2分钟,即120秒
|
# 没有语音输入多久后断开连接(秒),默认2分钟,即120秒
|
||||||
close_connection_no_voice_time: 120
|
close_connection_no_voice_time: 120
|
||||||
# TTS请求超时时间(秒)
|
|
||||||
tts_timeout: 10
|
|
||||||
# 开场是否回复唤醒词
|
# 开场是否回复唤醒词
|
||||||
enable_greeting: true
|
enable_greeting: true
|
||||||
# 说完话是否开启提示音
|
# 说完话是否开启提示音
|
||||||
@@ -899,7 +897,7 @@ TTS:
|
|||||||
sample_rate: 24000 # 采样率 [websocket默认24000,http默认0 自动选择]
|
sample_rate: 24000 # 采样率 [websocket默认24000,http默认0 自动选择]
|
||||||
speed: 1.0 # 语速,1.0 表示正常语速,>1 表示加快,<1 表示减慢
|
speed: 1.0 # 语速,1.0 表示正常语速,>1 表示加快,<1 表示减慢
|
||||||
volume: 1.0 # 音量,1.0 表示正常音量,>1 表示增大,<1 表示减小
|
volume: 1.0 # 音量,1.0 表示正常音量,>1 表示增大,<1 表示减小
|
||||||
save_path: ./streaming_tts.wav # 服务器生成的语音文件保存路径
|
save_path: # 保存路径
|
||||||
IndexStreamTTS:
|
IndexStreamTTS:
|
||||||
# 基于Index-TTS-vLLM项目的TTS接口服务
|
# 基于Index-TTS-vLLM项目的TTS接口服务
|
||||||
# 参照教程:https://github.com/Ksuriuri/index-tts-vllm/blob/master/README.md
|
# 参照教程:https://github.com/Ksuriuri/index-tts-vllm/blob/master/README.md
|
||||||
|
|||||||
@@ -140,10 +140,6 @@ class ConnectionHandler:
|
|||||||
self.func_handler = None
|
self.func_handler = None
|
||||||
|
|
||||||
self.cmd_exit = self.config["exit_commands"]
|
self.cmd_exit = self.config["exit_commands"]
|
||||||
self.max_cmd_length = 0
|
|
||||||
for cmd in self.cmd_exit:
|
|
||||||
if len(cmd) > self.max_cmd_length:
|
|
||||||
self.max_cmd_length = len(cmd)
|
|
||||||
|
|
||||||
# 是否在聊天结束后关闭连接
|
# 是否在聊天结束后关闭连接
|
||||||
self.close_after_chat = False
|
self.close_after_chat = False
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
from config.logger import setup_logging
|
from config.logger import setup_logging
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
|
import dashscope
|
||||||
from dashscope import Application
|
from dashscope import Application
|
||||||
from core.providers.llm.base import LLMProviderBase
|
from core.providers.llm.base import LLMProviderBase
|
||||||
from core.utils.util import check_model_key
|
from core.utils.util import check_model_key
|
||||||
|
import time
|
||||||
|
|
||||||
TAG = __name__
|
TAG = __name__
|
||||||
logger = setup_logging()
|
logger = setup_logging()
|
||||||
@@ -15,6 +17,7 @@ class LLMProvider(LLMProviderBase):
|
|||||||
self.base_url = config.get("base_url")
|
self.base_url = config.get("base_url")
|
||||||
self.is_No_prompt = config.get("is_no_prompt")
|
self.is_No_prompt = config.get("is_no_prompt")
|
||||||
self.memory_id = config.get("ali_memory_id")
|
self.memory_id = config.get("ali_memory_id")
|
||||||
|
self.streaming_chunk_size = config.get("streaming_chunk_size", 3) # 每次流式返回的字符数
|
||||||
check_model_key("AliBLLLM", self.api_key)
|
check_model_key("AliBLLLM", self.api_key)
|
||||||
|
|
||||||
def response(self, session_id, dialogue):
|
def response(self, session_id, dialogue):
|
||||||
@@ -32,6 +35,8 @@ class LLMProvider(LLMProviderBase):
|
|||||||
"app_id": self.app_id,
|
"app_id": self.app_id,
|
||||||
"session_id": session_id,
|
"session_id": session_id,
|
||||||
"messages": dialogue,
|
"messages": dialogue,
|
||||||
|
# 开启SDK原生流式
|
||||||
|
"stream": True,
|
||||||
}
|
}
|
||||||
if self.memory_id != False:
|
if self.memory_id != False:
|
||||||
# 百练memory需要prompt参数
|
# 百练memory需要prompt参数
|
||||||
@@ -42,25 +47,63 @@ class LLMProvider(LLMProviderBase):
|
|||||||
f"【阿里百练API服务】处理后的prompt: {prompt}"
|
f"【阿里百练API服务】处理后的prompt: {prompt}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 可选地设置自定义API基地址(若配置为兼容模式URL则忽略)
|
||||||
|
if self.base_url and ("/api/" in self.base_url):
|
||||||
|
dashscope.base_http_api_url = self.base_url
|
||||||
|
|
||||||
responses = Application.call(**call_params)
|
responses = Application.call(**call_params)
|
||||||
if responses.status_code != HTTPStatus.OK:
|
|
||||||
logger.bind(tag=TAG).error(
|
# 流式处理(SDK在stream=True时返回可迭代对象;否则返回单次响应对象)
|
||||||
f"code={responses.status_code}, "
|
logger.bind(tag=TAG).debug(
|
||||||
f"message={responses.message}, "
|
f"【阿里百练API服务】构造参数: {dict(call_params, api_key='***')}"
|
||||||
f"请参考文档:https://help.aliyun.com/zh/model-studio/developer-reference/error-code"
|
)
|
||||||
)
|
|
||||||
yield "【阿里百练API服务响应异常】"
|
last_text = ""
|
||||||
else:
|
try:
|
||||||
logger.bind(tag=TAG).debug(
|
for resp in responses:
|
||||||
f"【阿里百练API服务】构造参数: {call_params}"
|
if resp.status_code != HTTPStatus.OK:
|
||||||
)
|
logger.bind(tag=TAG).error(
|
||||||
yield responses.output.text
|
f"code={resp.status_code}, message={resp.message}, 请参考文档:https://help.aliyun.com/zh/model-studio/developer-reference/error-code"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
current_text = getattr(getattr(resp, "output", None), "text", None)
|
||||||
|
if current_text is None:
|
||||||
|
continue
|
||||||
|
# SDK流式为增量覆盖,计算差量输出
|
||||||
|
if len(current_text) >= len(last_text):
|
||||||
|
delta = current_text[len(last_text):]
|
||||||
|
else:
|
||||||
|
# 避免偶发回退
|
||||||
|
delta = current_text
|
||||||
|
if delta:
|
||||||
|
yield delta
|
||||||
|
last_text = current_text
|
||||||
|
except TypeError:
|
||||||
|
# 非流式回落(一次性返回)
|
||||||
|
if responses.status_code != HTTPStatus.OK:
|
||||||
|
logger.bind(tag=TAG).error(
|
||||||
|
f"code={responses.status_code}, message={responses.message}, 请参考文档:https://help.aliyun.com/zh/model-studio/developer-reference/error-code"
|
||||||
|
)
|
||||||
|
yield "【阿里百练API服务响应异常】"
|
||||||
|
else:
|
||||||
|
full_text = getattr(getattr(responses, "output", None), "text", "")
|
||||||
|
logger.bind(tag=TAG).info(
|
||||||
|
f"【阿里百练API服务】完整响应长度: {len(full_text)}"
|
||||||
|
)
|
||||||
|
for i in range(0, len(full_text), self.streaming_chunk_size):
|
||||||
|
chunk = full_text[i:i + self.streaming_chunk_size]
|
||||||
|
if chunk:
|
||||||
|
yield chunk
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.bind(tag=TAG).error(f"【阿里百练API服务】响应异常: {e}")
|
logger.bind(tag=TAG).error(f"【阿里百练API服务】响应异常: {e}")
|
||||||
yield "【LLM服务响应异常】"
|
yield "【LLM服务响应异常】"
|
||||||
|
|
||||||
def response_with_functions(self, session_id, dialogue, functions=None):
|
def response_with_functions(self, session_id, dialogue, functions=None):
|
||||||
logger.bind(tag=TAG).error(
|
# 阿里百练当前未支持原生的 function call。为保持兼容,这里回退到普通文本流式输出。
|
||||||
f"阿里百练暂未实现完整的工具调用(function call),建议使用其他意图识别"
|
# 上层会按 (content, tool_calls) 的形式消费,这里始终返回 (token, None)
|
||||||
|
logger.bind(tag=TAG).warning(
|
||||||
|
"阿里百练未实现原生 function call,已回退为纯文本流式输出"
|
||||||
)
|
)
|
||||||
|
for token in self.response(session_id, dialogue):
|
||||||
|
yield token, None
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ class ServerMCPExecutor(ToolExecutor):
|
|||||||
"""初始化MCP管理器"""
|
"""初始化MCP管理器"""
|
||||||
if not self._initialized:
|
if not self._initialized:
|
||||||
self.mcp_manager = ServerMCPManager(self.conn)
|
self.mcp_manager = ServerMCPManager(self.conn)
|
||||||
await self.mcp_manager.initialize_servers()
|
|
||||||
self._initialized = True
|
self._initialized = True
|
||||||
|
await self.mcp_manager.initialize_servers()
|
||||||
|
|
||||||
async def execute(
|
async def execute(
|
||||||
self, conn, tool_name: str, arguments: Dict[str, Any]
|
self, conn, tool_name: str, arguments: Dict[str, Any]
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ class ServerMCPManager:
|
|||||||
|
|
||||||
# 输出当前支持的服务端MCP工具列表
|
# 输出当前支持的服务端MCP工具列表
|
||||||
if hasattr(self.conn, "func_handler") and self.conn.func_handler:
|
if hasattr(self.conn, "func_handler") and self.conn.func_handler:
|
||||||
|
# 刷新工具缓存以确保服务端MCP工具被正确加载
|
||||||
|
if hasattr(self.conn.func_handler, "tool_manager"):
|
||||||
|
self.conn.func_handler.tool_manager.refresh_tools()
|
||||||
self.conn.func_handler.current_support_functions()
|
self.conn.func_handler.current_support_functions()
|
||||||
|
|
||||||
def get_all_tools(self) -> List[Dict[str, Any]]:
|
def get_all_tools(self) -> List[Dict[str, Any]]:
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ class TTSProviderBase(ABC):
|
|||||||
def __init__(self, config, delete_audio_file):
|
def __init__(self, config, delete_audio_file):
|
||||||
self.interface_type = InterfaceType.NON_STREAM
|
self.interface_type = InterfaceType.NON_STREAM
|
||||||
self.conn = None
|
self.conn = None
|
||||||
self.tts_timeout = 10
|
|
||||||
self.delete_audio_file = delete_audio_file
|
self.delete_audio_file = delete_audio_file
|
||||||
self.audio_file_type = "wav"
|
self.audio_file_type = "wav"
|
||||||
self.output_file = config.get("output_dir", "tmp/")
|
self.output_file = config.get("output_dir", "tmp/")
|
||||||
@@ -192,7 +191,6 @@ class TTSProviderBase(ABC):
|
|||||||
|
|
||||||
async def open_audio_channels(self, conn):
|
async def open_audio_channels(self, conn):
|
||||||
self.conn = conn
|
self.conn = conn
|
||||||
self.tts_timeout = conn.config.get("tts_timeout", 10)
|
|
||||||
# tts 消化线程
|
# tts 消化线程
|
||||||
self.tts_priority_thread = threading.Thread(
|
self.tts_priority_thread = threading.Thread(
|
||||||
target=self.tts_text_priority_thread, daemon=True
|
target=self.tts_text_priority_thread, daemon=True
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import wave
|
|||||||
import websockets
|
import websockets
|
||||||
from core.providers.tts.base import TTSProviderBase
|
from core.providers.tts.base import TTSProviderBase
|
||||||
from config.logger import setup_logging
|
from config.logger import setup_logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
TAG = __name__
|
TAG = __name__
|
||||||
logger = setup_logging()
|
logger = setup_logging()
|
||||||
@@ -18,11 +19,12 @@ class TTSProvider(TTSProviderBase):
|
|||||||
super().__init__(config, delete_audio_file)
|
super().__init__(config, delete_audio_file)
|
||||||
self.url = config.get("url", "ws://192.168.1.10:8092/paddlespeech/tts/streaming")
|
self.url = config.get("url", "ws://192.168.1.10:8092/paddlespeech/tts/streaming")
|
||||||
self.protocol = config.get("protocol", "websocket")
|
self.protocol = config.get("protocol", "websocket")
|
||||||
|
|
||||||
if config.get("private_voice"):
|
if config.get("private_voice"):
|
||||||
self.spk_id = int(config.get("private_voice"))
|
self.spk_id = int(config.get("private_voice"))
|
||||||
else:
|
else:
|
||||||
self.spk_id = int(config.get("spk_id", "0"))
|
self.spk_id = int(config.get("spk_id", "0"))
|
||||||
|
|
||||||
sample_rate = config.get("sample_rate", 24000)
|
sample_rate = config.get("sample_rate", 24000)
|
||||||
self.sample_rate = float(sample_rate) if sample_rate else 24000
|
self.sample_rate = float(sample_rate) if sample_rate else 24000
|
||||||
|
|
||||||
@@ -32,7 +34,21 @@ class TTSProvider(TTSProviderBase):
|
|||||||
volume = config.get("volume", 1.0)
|
volume = config.get("volume", 1.0)
|
||||||
self.volume = float(volume) if volume else 1.0
|
self.volume = float(volume) if volume else 1.0
|
||||||
|
|
||||||
self.save_path = config.get("save_path", "./streaming_tts.wav")
|
self.delete_audio_file = config.get("delete_audio", True)
|
||||||
|
if not self.delete_audio_file:
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
save_path = config.get("save_path")
|
||||||
|
if save_path:
|
||||||
|
if not save_path.endswith('.wav'):
|
||||||
|
save_path = f"{save_path}_{timestamp}.wav"
|
||||||
|
else:
|
||||||
|
other_path = save_path[:-4]
|
||||||
|
save_path = f"{other_path}_{timestamp}.wav"
|
||||||
|
self.save_path = save_path
|
||||||
|
else:
|
||||||
|
self.save_path = f"./streaming_tts_{timestamp}.wav"
|
||||||
|
else:
|
||||||
|
self.save_path = None
|
||||||
|
|
||||||
async def pcm_to_wav(self, pcm_data: bytes, sample_rate: int = 24000, num_channels: int = 1,
|
async def pcm_to_wav(self, pcm_data: bytes, sample_rate: int = 24000, num_channels: int = 1,
|
||||||
bits_per_sample: int = 16) -> bytes:
|
bits_per_sample: int = 16) -> bytes:
|
||||||
@@ -151,6 +167,12 @@ class TTSProvider(TTSProviderBase):
|
|||||||
# 接收结束响应避免服务抛出异常
|
# 接收结束响应避免服务抛出异常
|
||||||
await ws.recv()
|
await ws.recv()
|
||||||
|
|
||||||
|
# 根据配置决定是否保存文件
|
||||||
|
if not self.delete_audio_file and self.save_path:
|
||||||
|
with open(self.save_path, "wb") as f:
|
||||||
|
f.write(wav_data)
|
||||||
|
logger.bind(tag=TAG).info(f"音频文件已保存到: {self.save_path}")
|
||||||
|
|
||||||
# 返回或保存音频数据
|
# 返回或保存音频数据
|
||||||
if output_file:
|
if output_file:
|
||||||
with open(output_file, "wb") as file_to_save:
|
with open(output_file, "wb") as file_to_save:
|
||||||
@@ -159,4 +181,4 @@ class TTSProvider(TTSProviderBase):
|
|||||||
return wav_data
|
return wav_data
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise Exception(f"Error during TTS WebSocket request: {e} while processing text: {text}")
|
raise Exception(f"Error during TTS WebSocket request: {e} while processing text: {text}")
|
||||||
@@ -29,12 +29,7 @@ hass_get_state_function_desc = {
|
|||||||
@register_function("hass_get_state", hass_get_state_function_desc, ToolType.SYSTEM_CTL)
|
@register_function("hass_get_state", hass_get_state_function_desc, ToolType.SYSTEM_CTL)
|
||||||
def hass_get_state(conn, entity_id=""):
|
def hass_get_state(conn, entity_id=""):
|
||||||
try:
|
try:
|
||||||
|
ha_response = handle_hass_get_state(conn, entity_id)
|
||||||
future = asyncio.run_coroutine_threadsafe(
|
|
||||||
handle_hass_get_state(conn, entity_id), conn.loop
|
|
||||||
)
|
|
||||||
# 添加10秒超时
|
|
||||||
ha_response = future.result(timeout=10)
|
|
||||||
return ActionResponse(Action.REQLLM, ha_response, None)
|
return ActionResponse(Action.REQLLM, ha_response, None)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
logger.bind(tag=TAG).error("获取Home Assistant状态超时")
|
logger.bind(tag=TAG).error("获取Home Assistant状态超时")
|
||||||
@@ -45,13 +40,13 @@ def hass_get_state(conn, entity_id=""):
|
|||||||
return ActionResponse(Action.ERROR, error_msg, None)
|
return ActionResponse(Action.ERROR, error_msg, None)
|
||||||
|
|
||||||
|
|
||||||
async def handle_hass_get_state(conn, entity_id):
|
def handle_hass_get_state(conn, entity_id):
|
||||||
ha_config = initialize_hass_handler(conn)
|
ha_config = initialize_hass_handler(conn)
|
||||||
api_key = ha_config.get("api_key")
|
api_key = ha_config.get("api_key")
|
||||||
base_url = ha_config.get("base_url")
|
base_url = ha_config.get("base_url")
|
||||||
url = f"{base_url}/api/states/{entity_id}"
|
url = f"{base_url}/api/states/{entity_id}"
|
||||||
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
||||||
response = requests.get(url, headers=headers)
|
response = requests.get(url, headers=headers, timeout=5)
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
responsetext = "设备状态:" + response.json()["state"] + " "
|
responsetext = "设备状态:" + response.json()["state"] + " "
|
||||||
logger.bind(tag=TAG).info(f"api返回内容: {response.json()}")
|
logger.bind(tag=TAG).info(f"api返回内容: {response.json()}")
|
||||||
|
|||||||
@@ -54,11 +54,7 @@ def hass_set_state(conn, entity_id="", state=None):
|
|||||||
if state is None:
|
if state is None:
|
||||||
state = {}
|
state = {}
|
||||||
try:
|
try:
|
||||||
future = asyncio.run_coroutine_threadsafe(
|
ha_response = handle_hass_set_state(conn, entity_id, state)
|
||||||
handle_hass_set_state(conn, entity_id, state), conn.loop
|
|
||||||
)
|
|
||||||
# 添加10秒超时
|
|
||||||
ha_response = future.result(timeout=10)
|
|
||||||
return ActionResponse(Action.REQLLM, ha_response, None)
|
return ActionResponse(Action.REQLLM, ha_response, None)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
logger.bind(tag=TAG).error("设置Home Assistant状态超时")
|
logger.bind(tag=TAG).error("设置Home Assistant状态超时")
|
||||||
@@ -69,7 +65,7 @@ def hass_set_state(conn, entity_id="", state=None):
|
|||||||
return ActionResponse(Action.ERROR, error_msg, None)
|
return ActionResponse(Action.ERROR, error_msg, None)
|
||||||
|
|
||||||
|
|
||||||
async def handle_hass_set_state(conn, entity_id, state):
|
def handle_hass_set_state(conn, entity_id, state):
|
||||||
ha_config = initialize_hass_handler(conn)
|
ha_config = initialize_hass_handler(conn)
|
||||||
api_key = ha_config.get("api_key")
|
api_key = ha_config.get("api_key")
|
||||||
base_url = ha_config.get("base_url")
|
base_url = ha_config.get("base_url")
|
||||||
@@ -169,7 +165,7 @@ async def handle_hass_set_state(conn, entity_id, state):
|
|||||||
data = {"entity_id": entity_id, arg: value}
|
data = {"entity_id": entity_id, arg: value}
|
||||||
url = f"{base_url}/api/services/{domain}/{action}"
|
url = f"{base_url}/api/services/{domain}/{action}"
|
||||||
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
||||||
response = requests.post(url, headers=headers, json=data)
|
response = requests.post(url, headers=headers, json=data, timeout=5) # 设置5秒超时
|
||||||
logger.bind(tag=TAG).info(
|
logger.bind(tag=TAG).info(
|
||||||
f"设置状态:{description},url:{url},return_code:{response.status_code}"
|
f"设置状态:{description},url:{url},return_code:{response.status_code}"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import BlockingQueue from './utils/BlockingQueue.js';
|
||||||
|
import { log } from './utils/logger.js';
|
||||||
|
|
||||||
|
// 音频流播放上下文类
|
||||||
|
export class StreamingContext {
|
||||||
|
constructor(opusDecoder, audioContext, sampleRate, channels, minAudioDuration) {
|
||||||
|
this.opusDecoder = opusDecoder;
|
||||||
|
this.audioContext = audioContext;
|
||||||
|
|
||||||
|
// 音频参数
|
||||||
|
this.sampleRate = sampleRate;
|
||||||
|
this.channels = channels;
|
||||||
|
this.minAudioDuration = minAudioDuration;
|
||||||
|
|
||||||
|
// 初始化队列和状态
|
||||||
|
this.queue = []; // 已解码的PCM队列。正在播放
|
||||||
|
this.activeQueue = new BlockingQueue(); // 已解码的PCM队列。准备播放
|
||||||
|
this.pendingAudioBufferQueue = []; // 待处理的缓存队列
|
||||||
|
this.audioBufferQueue = new BlockingQueue(); // 缓存队列
|
||||||
|
this.playing = false; // 是否正在播放
|
||||||
|
this.endOfStream = false; // 是否收到结束信号
|
||||||
|
this.source = null; // 当前音频源
|
||||||
|
this.totalSamples = 0; // 累积的总样本数
|
||||||
|
this.lastPlayTime = 0; // 上次播放的时间戳
|
||||||
|
}
|
||||||
|
|
||||||
|
// 缓存音频数组
|
||||||
|
pushAudioBuffer(item) {
|
||||||
|
this.audioBufferQueue.enqueue(...item);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取需要处理缓存队列,单线程:在audioBufferQueue一直更新的状态下不会出现安全问题
|
||||||
|
async getPendingAudioBufferQueue() {
|
||||||
|
// 原子交换 + 清空
|
||||||
|
[this.pendingAudioBufferQueue, this.audioBufferQueue] = [await this.audioBufferQueue.dequeue(), new BlockingQueue()];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取正在播放已解码的PCM队列,单线程:在activeQueue一直更新的状态下不会出现安全问题
|
||||||
|
async getQueue(minSamples) {
|
||||||
|
let TepArray = [];
|
||||||
|
const num = minSamples - this.queue.length > 0 ? minSamples - this.queue.length : 1;
|
||||||
|
// 原子交换 + 清空
|
||||||
|
[TepArray, this.activeQueue] = [await this.activeQueue.dequeue(num), new BlockingQueue()];
|
||||||
|
this.queue.push(...TepArray);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将Int16音频数据转换为Float32音频数据
|
||||||
|
convertInt16ToFloat32(int16Data) {
|
||||||
|
const float32Data = new Float32Array(int16Data.length);
|
||||||
|
for (let i = 0; i < int16Data.length; i++) {
|
||||||
|
// 将[-32768,32767]范围转换为[-1,1]
|
||||||
|
float32Data[i] = int16Data[i] / (int16Data[i] < 0 ? 0x8000 : 0x7FFF);
|
||||||
|
}
|
||||||
|
return float32Data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将Opus数据解码为PCM
|
||||||
|
async decodeOpusFrames() {
|
||||||
|
if (!this.opusDecoder) {
|
||||||
|
log('Opus解码器未初始化,无法解码', 'error');
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
log('Opus解码器启动', 'info');
|
||||||
|
}
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
let decodedSamples = [];
|
||||||
|
for (const frame of this.pendingAudioBufferQueue) {
|
||||||
|
try {
|
||||||
|
// 使用Opus解码器解码
|
||||||
|
const frameData = this.opusDecoder.decode(frame);
|
||||||
|
if (frameData && frameData.length > 0) {
|
||||||
|
// 转换为Float32
|
||||||
|
const floatData = this.convertInt16ToFloat32(frameData);
|
||||||
|
// 使用循环替代展开运算符
|
||||||
|
for (let i = 0; i < floatData.length; i++) {
|
||||||
|
decodedSamples.push(floatData[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log("Opus解码失败: " + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decodedSamples.length > 0) {
|
||||||
|
// 使用循环替代展开运算符
|
||||||
|
for (let i = 0; i < decodedSamples.length; i++) {
|
||||||
|
this.activeQueue.enqueue(decodedSamples[i]);
|
||||||
|
}
|
||||||
|
this.totalSamples += decodedSamples.length;
|
||||||
|
} else {
|
||||||
|
log('没有成功解码的样本', 'warning');
|
||||||
|
}
|
||||||
|
await this.getPendingAudioBufferQueue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开始播放音频
|
||||||
|
async startPlaying() {
|
||||||
|
while (true) {
|
||||||
|
// 如果累积了至少0.3秒的音频,开始播放
|
||||||
|
const minSamples = this.sampleRate * this.minAudioDuration * 3;
|
||||||
|
if (!this.playing && this.queue.length < minSamples) {
|
||||||
|
await this.getQueue(minSamples);
|
||||||
|
}
|
||||||
|
this.playing = true;
|
||||||
|
while (this.playing && this.queue.length) {
|
||||||
|
// 创建新的音频缓冲区
|
||||||
|
const minPlaySamples = Math.min(this.queue.length, this.sampleRate);
|
||||||
|
const currentSamples = this.queue.splice(0, minPlaySamples);
|
||||||
|
|
||||||
|
const audioBuffer = this.audioContext.createBuffer(this.channels, currentSamples.length, this.sampleRate);
|
||||||
|
audioBuffer.copyToChannel(new Float32Array(currentSamples), 0);
|
||||||
|
|
||||||
|
// 创建音频源
|
||||||
|
this.source = this.audioContext.createBufferSource();
|
||||||
|
this.source.buffer = audioBuffer;
|
||||||
|
|
||||||
|
// 创建增益节点用于平滑过渡
|
||||||
|
const gainNode = this.audioContext.createGain();
|
||||||
|
|
||||||
|
// 应用淡入淡出效果避免爆音
|
||||||
|
const fadeDuration = 0.02; // 20毫秒
|
||||||
|
gainNode.gain.setValueAtTime(0, this.audioContext.currentTime);
|
||||||
|
gainNode.gain.linearRampToValueAtTime(1, this.audioContext.currentTime + fadeDuration);
|
||||||
|
|
||||||
|
const duration = audioBuffer.duration;
|
||||||
|
if (duration > fadeDuration * 2) {
|
||||||
|
gainNode.gain.setValueAtTime(1, this.audioContext.currentTime + duration - fadeDuration);
|
||||||
|
gainNode.gain.linearRampToValueAtTime(0, this.audioContext.currentTime + duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 连接节点并开始播放
|
||||||
|
this.source.connect(gainNode);
|
||||||
|
gainNode.connect(this.audioContext.destination);
|
||||||
|
|
||||||
|
this.lastPlayTime = this.audioContext.currentTime;
|
||||||
|
log(`开始播放 ${currentSamples.length} 个样本,约 ${(currentSamples.length / this.sampleRate).toFixed(2)} 秒`, 'info');
|
||||||
|
this.source.start();
|
||||||
|
}
|
||||||
|
await this.getQueue(minSamples);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建streamingContext实例的工厂函数
|
||||||
|
export function createStreamingContext(opusDecoder, audioContext, sampleRate, channels, minAudioDuration) {
|
||||||
|
return new StreamingContext(opusDecoder, audioContext, sampleRate, channels, minAudioDuration);
|
||||||
|
}
|
||||||
@@ -181,6 +181,7 @@
|
|||||||
import { checkOpusLoaded, initOpusEncoder } from './js/opus.js';
|
import { checkOpusLoaded, initOpusEncoder } from './js/opus.js';
|
||||||
import { addMessage } from './js/document.js'
|
import { addMessage } from './js/document.js'
|
||||||
import BlockingQueue from './js/utils/BlockingQueue.js'
|
import BlockingQueue from './js/utils/BlockingQueue.js'
|
||||||
|
import { createStreamingContext } from './js/StreamingContext.js'
|
||||||
// 需要加载的脚本列表 - 移除Opus依赖
|
// 需要加载的脚本列表 - 移除Opus依赖
|
||||||
const scriptFiles = [];
|
const scriptFiles = [];
|
||||||
|
|
||||||
@@ -336,125 +337,7 @@
|
|||||||
|
|
||||||
// 创建流式播放上下文
|
// 创建流式播放上下文
|
||||||
if (!streamingContext) {
|
if (!streamingContext) {
|
||||||
streamingContext = {
|
streamingContext = createStreamingContext(opusDecoder, audioContext, SAMPLE_RATE, CHANNELS, MIN_AUDIO_DURATION);
|
||||||
queue: [], // 已解码的PCM队列。正在播放
|
|
||||||
activeQueue: new BlockingQueue(), // 已解码的PCM队列。准备播放
|
|
||||||
pendingAudioBufferQueue: [], // 待处理的缓存队列
|
|
||||||
audioBufferQueue: new BlockingQueue(), // 缓存队列
|
|
||||||
playing: false, // 是否正在播放
|
|
||||||
endOfStream: false, // 是否收到结束信号
|
|
||||||
source: null, // 当前音频源
|
|
||||||
totalSamples: 0, // 累积的总样本数
|
|
||||||
lastPlayTime: 0, // 上次播放的时间戳
|
|
||||||
|
|
||||||
|
|
||||||
// 缓存音频数组
|
|
||||||
pushAudioBuffer: function (item) {
|
|
||||||
this.audioBufferQueue.enqueue(...item)
|
|
||||||
},
|
|
||||||
|
|
||||||
// 获取需要处理缓存队列,单线程:在audioBufferQueue一直更新的状态下不会出现安全问题
|
|
||||||
getPendingAudioBufferQueue: async function () {
|
|
||||||
// 原子交换 + 清空
|
|
||||||
[this.pendingAudioBufferQueue, this.audioBufferQueue] = [await this.audioBufferQueue.dequeue(), new BlockingQueue()];
|
|
||||||
|
|
||||||
},
|
|
||||||
// 获取正在播放已解码的PCM队列,单线程:在activeQueue一直更新的状态下不会出现安全问题
|
|
||||||
getQueue: async function (minSamples) {
|
|
||||||
let TepArray = []
|
|
||||||
const num = minSamples - this.queue.length > 0 ? minSamples - this.queue.length : 1;
|
|
||||||
// 原子交换 + 清空
|
|
||||||
[TepArray, this.activeQueue] = [await this.activeQueue.dequeue(num), new BlockingQueue()];
|
|
||||||
this.queue.push(...TepArray)
|
|
||||||
},
|
|
||||||
// 将Opus数据解码为PCM
|
|
||||||
decodeOpusFrames: async function () {
|
|
||||||
if (!opusDecoder) {
|
|
||||||
log('Opus解码器未初始化,无法解码', 'error');
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
log('Opus解码器启动', 'info');
|
|
||||||
}
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
let decodedSamples = [];
|
|
||||||
for (const frame of this.pendingAudioBufferQueue) {
|
|
||||||
try {
|
|
||||||
// 使用Opus解码器解码
|
|
||||||
const frameData = opusDecoder.decode(frame);
|
|
||||||
if (frameData && frameData.length > 0) {
|
|
||||||
// 转换为Float32
|
|
||||||
const floatData = convertInt16ToFloat32(frameData);
|
|
||||||
// 使用循环替代展开运算符
|
|
||||||
for (let i = 0; i < floatData.length; i++) {
|
|
||||||
decodedSamples.push(floatData[i]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
log("Opus解码失败: " + error.message, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (decodedSamples.length > 0) {
|
|
||||||
// 使用循环替代展开运算符
|
|
||||||
for (let i = 0; i < decodedSamples.length; i++) {
|
|
||||||
this.activeQueue.enqueue(decodedSamples[i]);
|
|
||||||
}
|
|
||||||
this.totalSamples += decodedSamples.length;
|
|
||||||
} else {
|
|
||||||
log('没有成功解码的样本', 'warning');
|
|
||||||
}
|
|
||||||
await this.getPendingAudioBufferQueue();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// 开始播放音频
|
|
||||||
startPlaying: async function () {
|
|
||||||
while (true) {
|
|
||||||
// 如果累积了至少0.3秒的音频,开始播放
|
|
||||||
const minSamples = SAMPLE_RATE * MIN_AUDIO_DURATION * 3;
|
|
||||||
if (!this.playing && this.queue.length < minSamples) {
|
|
||||||
await this.getQueue(minSamples)
|
|
||||||
}
|
|
||||||
this.playing = true;
|
|
||||||
while (this.playing && this.queue.length) {
|
|
||||||
// 创建新的音频缓冲区
|
|
||||||
const minPlaySamples = Math.min(this.queue.length, SAMPLE_RATE);
|
|
||||||
const currentSamples = this.queue.splice(0, minPlaySamples);
|
|
||||||
|
|
||||||
const audioBuffer = audioContext.createBuffer(CHANNELS, currentSamples.length, SAMPLE_RATE);
|
|
||||||
audioBuffer.copyToChannel(new Float32Array(currentSamples), 0);
|
|
||||||
|
|
||||||
// 创建音频源
|
|
||||||
this.source = audioContext.createBufferSource();
|
|
||||||
this.source.buffer = audioBuffer;
|
|
||||||
|
|
||||||
// 创建增益节点用于平滑过渡
|
|
||||||
const gainNode = audioContext.createGain();
|
|
||||||
|
|
||||||
// 应用淡入淡出效果避免爆音
|
|
||||||
const fadeDuration = 0.02; // 20毫秒
|
|
||||||
gainNode.gain.setValueAtTime(0, audioContext.currentTime);
|
|
||||||
gainNode.gain.linearRampToValueAtTime(1, audioContext.currentTime + fadeDuration);
|
|
||||||
|
|
||||||
const duration = audioBuffer.duration;
|
|
||||||
if (duration > fadeDuration * 2) {
|
|
||||||
gainNode.gain.setValueAtTime(1, audioContext.currentTime + duration - fadeDuration);
|
|
||||||
gainNode.gain.linearRampToValueAtTime(0, audioContext.currentTime + duration);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 连接节点并开始播放
|
|
||||||
this.source.connect(gainNode);
|
|
||||||
gainNode.connect(audioContext.destination);
|
|
||||||
|
|
||||||
this.lastPlayTime = audioContext.currentTime;
|
|
||||||
log(`开始播放 ${currentSamples.length} 个样本,约 ${(currentSamples.length / SAMPLE_RATE).toFixed(2)} 秒`, 'info');
|
|
||||||
this.source.start();
|
|
||||||
}
|
|
||||||
await this.getQueue(minSamples)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
streamingContext.decodeOpusFrames();
|
streamingContext.decodeOpusFrames();
|
||||||
@@ -467,15 +350,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将Int16音频数据转换为Float32音频数据
|
|
||||||
function convertInt16ToFloat32(int16Data) {
|
|
||||||
const float32Data = new Float32Array(int16Data.length);
|
|
||||||
for (let i = 0; i < int16Data.length; i++) {
|
|
||||||
// 将[-32768,32767]范围转换为[-1,1]
|
|
||||||
float32Data[i] = int16Data[i] / (int16Data[i] < 0 ? 0x8000 : 0x7FFF);
|
|
||||||
}
|
|
||||||
return float32Data;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 初始化Opus解码器 - 确保完全初始化完成后才返回
|
// 初始化Opus解码器 - 确保完全初始化完成后才返回
|
||||||
async function initOpusDecoder() {
|
async function initOpusDecoder() {
|
||||||
|
|||||||
Reference in New Issue
Block a user