Merge branch 'main' into py_test_Memory_powermem

This commit is contained in:
Sakura-RanChen
2026-02-04 16:02:50 +08:00
415 changed files with 72660 additions and 3336 deletions
+30 -14
View File
@@ -89,7 +89,8 @@ xiaozhi:
transport: websocket
audio_params:
format: opus
sample_rate: 16000
# Opus支持的采样率范围为[8000, 12000, 16000, 24000, 48000]
sample_rate: 24000
channels: 1
frame_duration: 60
@@ -202,6 +203,9 @@ prompt: |
# 默认系统提示词模板文件
prompt_template: agent-base-prompt.txt
# 系统错误时的回复
system_error_response: "主人,小智现在有点忙,我们稍后再试吧。"
# 结束语prompt
end_prompt:
enable: true # 是否开启结束语
@@ -762,13 +766,31 @@ TTS:
speaker: zh_female_wanwanxiaohe_moon_bigtts
# 开启WebSocket连接复用,默认复用(注意:复用后设备处于聆听状态时空闲链接会占并发数)
enable_ws_reuse: True
speech_rate: 0
loudness_rate: 0
pitch: 0
# 多情感音色参数,注意:当前仅部分音色支持设置情感。
# 相关音色列表:https://www.volcengine.com/docs/6561/1257544
emotion: "neutral" # 情感类型,可选值为:neutral、happy、sad、angry、fearful、disgusted、surprised
emotion_scale: 4 # 情感强度,可选值为:1~5,默认值为4
# 相关参数文档:https://www.volcengine.com/docs/6561/1329505
# 音频输出配置(audio_params)- 用户可自定义添加火山引擎支持的任何音频参数
audio_params:
speech_rate: 0 # 语速(-50~100)
loudness_rate: 0 # 音量(-50~100)
# 情感音色参数,注意:当前仅部分音色支持设置情感。
# 相关音色列表:https://www.volcengine.com/docs/6561/1257544
# emotion: "neutral" # 情感类型(仅部分音色支持):neutral、happy、sad、angry、fearful、disgusted、surprised
# emotion_scale: 4 # 情感强度(1~5)
# 高级文本处理配置(additions)- 用户可自定义添加火山引擎支持的任何高级参数
additions:
post_process:
pitch: 0 # 音高(-12~12)
# aigc_metadata: {} # AIGC元数据配置
# cache_config: {} # 缓存配置
# 混音控制配置(mix_speaker- 多音色混合(仅 TTS 1.0)
# 混音功能主要适用于豆包语音合成模型1.0的音色,使用时需要将req_params.speaker设置为custom_mix_bigtts
# mix_speaker:
# speakers:
# - source_speaker: zh_male_bvlazysheep
# mix_factor: 0.3
# - source_speaker: BV120_streaming
# mix_factor: 0.3
# - source_speaker: zh_male_ahu_conversation_wvae_bigtts
# mix_factor: 0.4
CosyVoiceSiliconflow:
type: siliconflow
# 硅基流动TTS
@@ -885,7 +907,6 @@ TTS:
# - "处理/(chu3)(li3)"
# - "危险/dangerous"
# audio_setting:
# sample_rate: 24000
# bitrate: 128000
# format: "mp3"
# channel: 1
@@ -913,7 +934,6 @@ TTS:
# 以下可不用设置,使用默认设置
# format: wav
# sample_rate: 16000
# volume: 50
# speech_rate: 0
# pitch_rate: 0
@@ -937,7 +957,6 @@ TTS:
host: nls-gateway-cn-beijing.aliyuncs.com
# 以下可不用设置,使用默认设置
# format: pcm # 音频格式:pcm、wav、mp3
# sample_rate: 16000 # 采样率:8000、16000、24000
# volume: 50 # 音量:0-100
# speech_rate: 0 # 语速:-500到500
# pitch_rate: 0 # 语调:-500到500
@@ -1052,7 +1071,6 @@ TTS:
protocol: websocket # protocol choices = ['websocket', 'http']
url: ws://127.0.0.1:8092/paddlespeech/tts/streaming # TTS 服务的 URL 地址,指向本地服务器 [websocket默认ws://127.0.0.1:8092/paddlespeech/tts/streaminghttp默认http://127.0.0.1:8090/paddlespeech/tts]
spk_id: 0 # 发音人 ID,0 通常表示默认的发音人
sample_rate: 24000 # 采样率 [websocket默认24000http默认0 自动选择]
speed: 1.0 # 语速,1.0 表示正常语速,>1 表示加快,<1 表示减慢
volume: 1.0 # 音量,1.0 表示正常音量,>1 表示增大,<1 表示减小
save_path: # 保存路径
@@ -1076,7 +1094,6 @@ TTS:
output_dir: tmp/
# 以下可不用设置,使用默认设置
# format: pcm # 音频格式:pcm、wav、mp3、opus
# sample_rate: 24000 # 采样率:16000, 24000, 48000
# volume: 50 # 音量:0-100
# rate: 1 # 语速:0.5~2
# pitch: 1 # 语调:0.5~2
@@ -1098,7 +1115,6 @@ TTS:
# stop_split: 0 # 关闭服务端拆句 不关闭:0,关闭:1
# remain: 0 # 是否保留原书面语的样子 保留:1, 不保留:0
# format: raw # 音频格式:raw(PCM), lame(MP3), speex, opus, opus-wb, opus-swb, speex-wb
# sample_rate: 24000 # 采样率:16000, 8000, 24000
# volume: 50 # 音量:0-100
# speed: 50 # 语速:0-100
# pitch: 50 # 语调:0-100
+1 -1
View File
@@ -5,7 +5,7 @@ from config.config_loader import load_config
from config.settings import check_config_file
from datetime import datetime
SERVER_VERSION = "0.8.11"
SERVER_VERSION = "0.9.1"
_logger_initialized = False
+78 -42
View File
@@ -40,8 +40,10 @@ from config.logger import setup_logging, build_module_string, create_connection_
from config.manage_api_client import DeviceNotFoundException, DeviceBindException
from core.utils.prompt_manager import PromptManager
from core.utils.voiceprint_provider import VoiceprintProvider
from core.utils.util import get_system_error_response
from core.utils import textUtils
TAG = __name__
auto_import_modules("plugins_func.functions")
@@ -85,6 +87,7 @@ class ConnectionHandler:
self.max_output_size = 0
self.chat_history_conf = 0
self.audio_format = "opus"
self.sample_rate = 24000 # 默认采样率,从客户端 hello 消息中动态更新
# 客户端状态相关
self.client_abort = False
@@ -134,7 +137,6 @@ class ConnectionHandler:
self.current_language_tag = None # 存储当前ASR识别的语言标签
# llm相关变量
self.llm_finish_task = True
self.dialogue = Dialogue()
# tts相关变量
@@ -206,6 +208,10 @@ class ConnectionHandler:
self.welcome_msg = self.config["xiaozhi"]
self.welcome_msg["session_id"] = self.session_id
# 从配置中读取采样率
self.sample_rate = self.welcome_msg["audio_params"]["sample_rate"]
self.logger.bind(tag=TAG).info(f"配置输出音频采样率为: {self.sample_rate}")
# 在后台初始化配置和组件(完全不阻塞主循环)
asyncio.create_task(self._background_initialize())
@@ -794,7 +800,6 @@ class ConnectionHandler:
# 为最顶层时新建会话ID和发送FIRST请求
if depth == 0:
self.llm_finish_task = False
self.sentence_id = str(uuid.uuid4().hex)
self.dialogue.put(Message(role="user", content=query))
self.tts.tts_text_queue.put(
@@ -870,46 +875,66 @@ class ConnectionHandler:
content_arguments = ""
self.client_abort = False
emotion_flag = True
for response in llm_responses:
if self.client_abort:
break
if self.intent_type == "function_call" and functions is not None:
content, tools_call = response
if "content" in response:
content = response["content"]
tools_call = None
if content is not None and len(content) > 0:
content_arguments += content
try:
for response in llm_responses:
if self.client_abort:
break
if self.intent_type == "function_call" and functions is not None:
content, tools_call = response
if "content" in response:
content = response["content"]
tools_call = None
if content is not None and len(content) > 0:
content_arguments += content
if not tool_call_flag and content_arguments.startswith("<tool_call>"):
# print("content_arguments", content_arguments)
tool_call_flag = True
if not tool_call_flag and content_arguments.startswith("<tool_call>"):
# print("content_arguments", content_arguments)
tool_call_flag = True
if tools_call is not None and len(tools_call) > 0:
tool_call_flag = True
self._merge_tool_calls(tool_calls_list, tools_call)
else:
content = response
if tools_call is not None and len(tools_call) > 0:
tool_call_flag = True
self._merge_tool_calls(tool_calls_list, tools_call)
else:
content = response
# 在llm回复中获取情绪表情,一轮对话只在开头获取一次
if emotion_flag and content is not None and content.strip():
asyncio.run_coroutine_threadsafe(
textUtils.get_emotion(self, content),
self.loop,
)
emotion_flag = False
if content is not None and len(content) > 0:
if not tool_call_flag:
response_message.append(content)
self.tts.tts_text_queue.put(
TTSMessageDTO(
sentence_id=self.sentence_id,
sentence_type=SentenceType.MIDDLE,
content_type=ContentType.TEXT,
content_detail=content,
)
# 在llm回复中获取情绪表情,一轮对话只在开头获取一次
if emotion_flag and content is not None and content.strip():
asyncio.run_coroutine_threadsafe(
textUtils.get_emotion(self, content),
self.loop,
)
emotion_flag = False
if content is not None and len(content) > 0:
if not tool_call_flag:
response_message.append(content)
self.tts.tts_text_queue.put(
TTSMessageDTO(
sentence_id=self.sentence_id,
sentence_type=SentenceType.MIDDLE,
content_type=ContentType.TEXT,
content_detail=content,
)
)
except Exception as e:
self.logger.bind(tag=TAG).error(f"LLM stream processing error: {e}")
self.tts.tts_text_queue.put(
TTSMessageDTO(
sentence_id=self.sentence_id,
sentence_type=SentenceType.MIDDLE,
content_type=ContentType.TEXT,
content_detail=get_system_error_response(self.config),
)
)
if depth == 0:
self.tts.tts_text_queue.put(
TTSMessageDTO(
sentence_id=self.sentence_id,
sentence_type=SentenceType.LAST,
content_type=ContentType.ACTION,
)
)
return
# 处理function call
if tool_call_flag:
bHasError = False
@@ -990,7 +1015,6 @@ class ConnectionHandler:
content_type=ContentType.ACTION,
)
)
self.llm_finish_task = True
# 使用lambda延迟计算,只有在DEBUG级别时才执行get_llm_dialogue()
self.logger.bind(tag=TAG).debug(
lambda: json.dumps(
@@ -1162,6 +1186,8 @@ class ConnectionHandler:
if self.tts:
await self.tts.close()
if self.asr:
await self.asr.close()
# 最后关闭线程池(避免阻塞)
if self.executor:
@@ -1210,11 +1236,21 @@ class ConnectionHandler:
f"清理结束: TTS队列大小={self.tts.tts_text_queue.qsize()}, 音频队列大小={self.tts.tts_audio_queue.qsize()}"
)
def reset_vad_states(self):
self.client_audio_buffer = bytearray()
def reset_audio_states(self):
"""
重置所有音频相关状态(VAD + ASR)
"""
# Reset VAD states
self.client_audio_buffer.clear()
self.client_have_voice = False
self.client_voice_stop = False
self.logger.bind(tag=TAG).debug("VAD states reset.")
self.client_voice_window.clear()
self.last_is_voice = False
# Clear ASR buffers
self.asr_audio.clear()
self.logger.bind(tag=TAG).debug("All audio states reset.")
def chat_and_close(self, text):
"""Chat with the user and then close the connection"""
@@ -11,8 +11,7 @@ from core.handle.sendAudioHandle import sendAudioMessage, send_tts_message
from core.utils.util import remove_punctuation_and_length, opus_datas_to_wav_bytes
from core.providers.tools.device_mcp import (
MCPClient,
send_mcp_initialize_message,
send_mcp_tools_list_request,
send_mcp_initialize_message
)
TAG = __name__
@@ -56,8 +55,6 @@ async def handleHelloMessage(conn, msg_json):
conn.mcp_client = MCPClient()
# 发送初始化
asyncio.create_task(send_mcp_initialize_message(conn))
# 发送mcp消息,获取tools列表
asyncio.create_task(send_mcp_tools_list_request(conn))
await conn.websocket.send(json.dumps(conn.welcome_msg))
@@ -145,7 +142,8 @@ async def wakeupWordsResponse(conn):
# 获取当前音色
voice = getattr(conn.tts, "voice", "default")
wav_bytes = opus_datas_to_wav_bytes(tts_result, sample_rate=16000)
# 使用链接的sample_rate
wav_bytes = opus_datas_to_wav_bytes(tts_result, sample_rate=conn.sample_rate)
file_path = wakeup_words_config.generate_file_path(voice)
with open(file_path, "wb") as f:
f.write(wav_bytes)
@@ -17,7 +17,6 @@ async def handleAudioMessage(conn, audio):
if hasattr(conn, "just_woken_up") and conn.just_woken_up:
have_voice = False
# 设置一个短暂延迟后恢复VAD检测
conn.asr_audio.clear()
if not hasattr(conn, "vad_resume_task") or conn.vad_resume_task.done():
conn.vad_resume_task = asyncio.create_task(resume_vad_detection(conn))
return
@@ -26,10 +26,9 @@ class ListenTextMessageHandler(TextMessageHandler):
f"客户端拾音模式:{conn.client_listen_mode}"
)
if msg_json["state"] == "start":
conn.client_have_voice = True
conn.client_voice_stop = False
# 设备从播放模式切回录音模式,清除所有音频状态和缓冲区
conn.reset_audio_states()
elif msg_json["state"] == "stop":
conn.client_have_voice = True
conn.client_voice_stop = True
if conn.asr.interface_type == InterfaceType.STREAM:
# 流式模式下,发送结束请求
@@ -38,14 +37,13 @@ class ListenTextMessageHandler(TextMessageHandler):
# 非流式模式:直接触发ASR识别
if len(conn.asr_audio) > 0:
asr_audio_task = conn.asr_audio.copy()
conn.asr_audio.clear()
conn.reset_vad_states()
conn.reset_audio_states()
if len(asr_audio_task) > 0:
await conn.asr.handle_voice_stop(conn, asr_audio_task)
elif msg_json["state"] == "detect":
conn.client_have_voice = False
conn.asr_audio.clear()
conn.reset_audio_states()
if "text" in msg_json:
conn.last_activity_time = time.time() * 1000
original_text = msg_json["text"] # 保留原始文本
@@ -213,36 +213,24 @@ class ASRProvider(ASRProviderBase):
return None
async def speech_to_text(
self, opus_data: List[bytes], session_id: str, audio_format="opus"
self, opus_data: List[bytes], session_id: str, audio_format="opus", artifacts=None
) -> Tuple[Optional[str], Optional[str]]:
"""将语音数据转换为文本"""
if self._is_token_expired():
logger.warning("Token已过期,正在自动刷新...")
self._refresh_token()
file_path = None
try:
# 解码Opus为PCM
if audio_format == "pcm":
pcm_data = opus_data
else:
pcm_data = self.decode_opus(opus_data)
combined_pcm_data = b"".join(pcm_data)
# 判断是否保存为WAV文件
if self.delete_audio_file:
pass
else:
file_path = self.save_audio_to_file(pcm_data, session_id)
if artifacts is None:
return "", None
# 发送请求并获取文本
text = await self._send_request(combined_pcm_data)
text = await self._send_request(artifacts.pcm_bytes)
if text:
return text, file_path
return text, artifacts.file_path
return "", file_path
return "", artifacts.file_path
except Exception as e:
logger.bind(tag=TAG).error(f"语音识别失败: {e}", exc_info=True)
return "", file_path
return "", None
@@ -126,16 +126,8 @@ class ASRProvider(ASRProviderBase):
await super().open_audio_channels(conn)
async def receive_audio(self, conn, audio, audio_have_voice):
# 初始化音频缓存
if not hasattr(conn, 'asr_audio_for_voiceprint'):
conn.asr_audio_for_voiceprint = []
# 存储音频数据
if audio:
conn.asr_audio_for_voiceprint.append(audio)
conn.asr_audio.append(audio)
conn.asr_audio = conn.asr_audio[-10:]
# 先调用父类方法处理基础逻辑
await super().receive_audio(conn, audio, audio_have_voice)
# 只在有声音且没有连接时建立连接(排除正在停止的情况)
if audio_have_voice and not self.is_processing and not self.asr_ws:
@@ -152,7 +144,7 @@ class ASRProvider(ASRProviderBase):
await self.asr_ws.send(pcm_frame)
except Exception as e:
logger.bind(tag=TAG).warning(f"发送音频失败: {str(e)}")
await self._cleanup(conn)
await self._cleanup()
async def _start_recognition(self, conn):
"""开始识别会话"""
@@ -204,8 +196,10 @@ class ASRProvider(ASRProviderBase):
"""转发识别结果"""
try:
while not conn.stop_event.is_set():
# 获取当前连接的音频数据
audio_data = conn.asr_audio
try:
response = await asyncio.wait_for(self.asr_ws.recv(), timeout=1.0)
response = await self.asr_ws.recv()
result = json.loads(response)
header = result.get("header", {})
@@ -257,19 +251,12 @@ class ASRProvider(ASRProviderBase):
# 手动模式下,只有在收到stop信号后才触发处理(仅处理一次)
if conn.client_voice_stop:
audio_data = getattr(conn, 'asr_audio_for_voiceprint', [])
if len(audio_data) > 0:
logger.bind(tag=TAG).debug("收到最终识别结果,触发处理")
await self.handle_voice_stop(conn, audio_data)
# 清理音频缓存
conn.asr_audio.clear()
conn.reset_vad_states()
logger.bind(tag=TAG).debug("收到最终识别结果,触发处理")
await self.handle_voice_stop(conn, audio_data)
break
else:
# 自动模式下直接覆盖
self.text = text
conn.reset_vad_states()
audio_data = getattr(conn, 'asr_audio_for_voiceprint', [])
await self.handle_voice_stop(conn, audio_data)
break
@@ -289,11 +276,7 @@ class ASRProvider(ASRProviderBase):
finally:
# 清理连接的音频缓存
await self._cleanup()
if conn:
if hasattr(conn, 'asr_audio_for_voiceprint'):
conn.asr_audio_for_voiceprint = []
if hasattr(conn, 'asr_audio'):
conn.asr_audio = []
conn.reset_audio_states()
async def _send_stop_request(self):
"""发送停止识别请求(不关闭连接)"""
@@ -341,7 +324,7 @@ class ASRProvider(ASRProviderBase):
logger.bind(tag=TAG).debug("ASR会话清理完成")
async def speech_to_text(self, opus_data, session_id, audio_format):
async def speech_to_text(self, opus_data, session_id, audio_format, artifacts=None):
"""获取识别结果"""
result = self.text
self.text = ""
@@ -349,7 +332,7 @@ class ASRProvider(ASRProviderBase):
async def close(self):
"""关闭资源"""
await self._cleanup(None)
await self._cleanup()
if hasattr(self, 'decoder') and self.decoder is not None:
try:
del self.decoder
@@ -52,16 +52,8 @@ class ASRProvider(ASRProviderBase):
await super().open_audio_channels(conn)
async def receive_audio(self, conn, audio, audio_have_voice):
# 初始化音频缓存
if not hasattr(conn, 'asr_audio_for_voiceprint'):
conn.asr_audio_for_voiceprint = []
# 存储音频数据
if audio:
conn.asr_audio_for_voiceprint.append(audio)
conn.asr_audio.append(audio)
conn.asr_audio = conn.asr_audio[-10:]
# 先调用父类方法处理基础逻辑
await super().receive_audio(conn, audio, audio_have_voice)
# 只在有声音且没有连接时建立连接
if audio_have_voice and not self.is_processing and not self.asr_ws:
@@ -166,6 +158,8 @@ class ASRProvider(ASRProviderBase):
"""转发识别结果"""
try:
while not conn.stop_event.is_set():
# 获取当前连接的音频数据
audio_data = conn.asr_audio
try:
response = await asyncio.wait_for(self.asr_ws.recv(), timeout=1.0)
result = json.loads(response)
@@ -214,19 +208,12 @@ class ASRProvider(ASRProviderBase):
# 手动模式下,只有在收到stop信号后才触发处理
if conn.client_voice_stop:
audio_data = getattr(conn, 'asr_audio_for_voiceprint', [])
if len(audio_data) > 0:
logger.bind(tag=TAG).debug("收到最终识别结果,触发处理")
await self.handle_voice_stop(conn, audio_data)
# 清理音频缓存
conn.asr_audio.clear()
conn.reset_vad_states()
logger.bind(tag=TAG).debug("收到最终识别结果,触发处理")
await self.handle_voice_stop(conn, audio_data)
break
else:
# 自动模式下直接覆盖
self.text = text
conn.reset_vad_states()
audio_data = getattr(conn, 'asr_audio_for_voiceprint', [])
await self.handle_voice_stop(conn, audio_data)
break
@@ -257,11 +244,7 @@ class ASRProvider(ASRProviderBase):
finally:
# 清理连接的音频缓存
await self._cleanup()
if conn:
if hasattr(conn, 'asr_audio_for_voiceprint'):
conn.asr_audio_for_voiceprint = []
if hasattr(conn, 'asr_audio'):
conn.asr_audio = []
conn.reset_audio_states()
async def _send_stop_request(self):
"""发送停止请求(用于手动模式停止录音)"""
@@ -325,7 +308,7 @@ class ASRProvider(ASRProviderBase):
logger.bind(tag=TAG).debug("ASR会话清理完成")
async def speech_to_text(self, opus_data, session_id, audio_format):
async def speech_to_text(self, opus_data, session_id, audio_format, artifacts=None):
"""获取识别结果"""
result = self.text
self.text = ""
@@ -30,37 +30,26 @@ class ASRProvider(ASRProviderBase):
os.makedirs(self.output_dir, exist_ok=True)
async def speech_to_text(
self, opus_data: List[bytes], session_id: str, audio_format="opus"
self, opus_data: List[bytes], session_id: str, audio_format="opus", artifacts=None
) -> Tuple[Optional[str], Optional[str]]:
"""将语音数据转换为文本"""
if not opus_data:
logger.bind(tag=TAG).warning("音频数据为空!")
return None, None
file_path = None
try:
# 检查配置是否已设置
if not self.app_id or not self.api_key or not self.secret_key:
logger.bind(tag=TAG).error("百度语音识别配置未设置,无法进行识别")
return None, file_path
return None, None
# 将Opus音频数据解码为PCM
if audio_format == "pcm":
pcm_data = opus_data
else:
pcm_data = self.decode_opus(opus_data)
combined_pcm_data = b"".join(pcm_data)
# 判断是否保存为WAV文件
if self.delete_audio_file:
pass
else:
self.save_audio_to_file(pcm_data, session_id)
if artifacts is None:
return "", None
start_time = time.time()
# 识别本地文件
result = self.client.asr(
combined_pcm_data,
artifacts.pcm_bytes,
"pcm",
16000,
{
@@ -73,13 +62,13 @@ class ASRProvider(ASRProviderBase):
f"百度语音识别耗时: {time.time() - start_time:.3f}s | 结果: {result}"
)
result = result["result"][0]
return result, file_path
return result, artifacts.file_path
else:
raise Exception(
f"百度语音识别失败,错误码: {result['err_no']},错误信息: {result['err_msg']}"
)
return None, file_path
return None, artifacts.file_path
except Exception as e:
logger.bind(tag=TAG).error(f"处理音频时发生错误!{e}", exc_info=True)
return None, file_path
return None, None
+144 -30
View File
@@ -8,14 +8,18 @@ import queue
import asyncio
import traceback
import threading
import shutil
import opuslib_next
from abc import ABC, abstractmethod
from config.logger import setup_logging
from typing import Optional, Tuple, List
from typing import Optional, Tuple, List, NamedTuple
from core.providers.asr.dto.dto import InterfaceType
from core.handle.receiveAudioHandle import startToChat
from core.handle.reportHandle import enqueue_asr_report
from core.utils.util import remove_punctuation_and_length
from core.handle.receiveAudioHandle import handleAudioMessage
import tempfile
TAG = __name__
logger = setup_logging()
@@ -57,18 +61,17 @@ class ASRProviderBase(ABC):
conn.asr_audio.append(audio)
else:
# 自动/实时模式:使用VAD检测
have_voice = audio_have_voice
conn.asr_audio.append(audio)
if not have_voice and not conn.client_have_voice:
# 如果没有语音,且之前也没有声音,缓存部分音频
if not audio_have_voice and not conn.client_have_voice:
conn.asr_audio = conn.asr_audio[-10:]
return
# 自动模式下通过VAD检测到语音停止时触发识别
if conn.client_voice_stop:
if conn.asr.interface_type != InterfaceType.STREAM and conn.client_voice_stop:
asr_audio_task = conn.asr_audio.copy()
conn.asr_audio.clear()
conn.reset_vad_states()
conn.reset_audio_states()
if len(asr_audio_task) > 15:
await self.handle_voice_stop(conn, asr_audio_task)
@@ -93,10 +96,14 @@ class ASRProviderBase(ABC):
wav_data = self._pcm_to_wav(combined_pcm_data)
# 定义ASR任务
asr_task = self.speech_to_text(asr_audio_task, conn.session_id, conn.audio_format)
asr_task = self.speech_to_text_wrapper(
asr_audio_task, conn.session_id, conn.audio_format
)
if conn.voiceprint_provider and wav_data:
voiceprint_task = conn.voiceprint_provider.identify_speaker(wav_data, conn.session_id)
voiceprint_task = conn.voiceprint_provider.identify_speaker(
wav_data, conn.session_id
)
# 并发等待两个结果
asr_result, voiceprint_result = await asyncio.gather(
asr_task, voiceprint_task, return_exceptions=True
@@ -159,20 +166,20 @@ class ASRProviderBase(ABC):
if text_len > 0:
# 使用自定义模块进行上报
await startToChat(conn, enhanced_text)
enqueue_asr_report(conn, enhanced_text, asr_audio_task)
audio_snapshot = asr_audio_task.copy()
enqueue_asr_report(conn, enhanced_text, audio_snapshot)
except Exception as e:
logger.bind(tag=TAG).error(f"处理语音停止失败: {e}")
import traceback
logger.bind(tag=TAG).debug(f"异常详情: {traceback.format_exc()}")
def _build_enhanced_text(self, text: str, speaker_name: Optional[str]) -> str:
"""构建包含说话人信息的文本(仅用于纯文本ASR)"""
if speaker_name and speaker_name.strip():
return json.dumps({
"speaker": speaker_name,
"content": text
}, ensure_ascii=False)
return json.dumps(
{"speaker": speaker_name, "content": text}, ensure_ascii=False
)
else:
return text
@@ -181,23 +188,23 @@ class ASRProviderBase(ABC):
if len(pcm_data) == 0:
logger.bind(tag=TAG).warning("PCM数据为空,无法转换WAV")
return b""
# 确保数据长度是偶数(16位音频)
if len(pcm_data) % 2 != 0:
pcm_data = pcm_data[:-1]
# 创建WAV文件头
wav_buffer = io.BytesIO()
try:
with wave.open(wav_buffer, 'wb') as wav_file:
wav_file.setnchannels(1) # 单声道
wav_file.setsampwidth(2) # 16位
with wave.open(wav_buffer, "wb") as wav_file:
wav_file.setnchannels(1) # 单声道
wav_file.setsampwidth(2) # 16位
wav_file.setframerate(16000) # 16kHz采样率
wav_file.writeframes(pcm_data)
wav_buffer.seek(0)
wav_data = wav_buffer.read()
return wav_data
except Exception as e:
logger.bind(tag=TAG).error(f"WAV转换失败: {e}")
@@ -206,6 +213,44 @@ class ASRProviderBase(ABC):
def stop_ws_connection(self):
pass
async def close(self):
pass
class AudioArtifacts(NamedTuple):
pcm_frames: List[bytes]
"""PCM音频帧列表"""
pcm_bytes: bytes
"""合并后的PCM音频字节数据"""
file_path: Optional[str]
"""WAV文件路径"""
temp_path: Optional[str]
"""临时WAV文件路径"""
def get_current_artifacts(self) -> Optional["ASRProviderBase.AudioArtifacts"]:
return self._current_artifacts
def requires_file(self) -> bool:
"""是否需要文件输入"""
return False
def prefers_temp_file(self) -> bool:
"""是否优先使用临时文件"""
return False
def build_temp_file(self, pcm_bytes: bytes) -> Optional[str]:
try:
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as temp_file:
temp_path = temp_file.name
with wave.open(temp_path, "wb") as wav_file:
wav_file.setnchannels(1)
wav_file.setsampwidth(2)
wav_file.setframerate(16000)
wav_file.writeframes(pcm_bytes)
return temp_path
except Exception as e:
logger.bind(tag=TAG).error(f"临时音频文件生成失败: {e}")
return None
def save_audio_to_file(self, pcm_data: List[bytes], session_id: str) -> str:
"""PCM数据保存为WAV文件"""
module_name = __name__.split(".")[-1]
@@ -220,11 +265,80 @@ class ASRProviderBase(ABC):
return file_path
@abstractmethod
async def speech_to_text(
async def speech_to_text_wrapper(
self, opus_data: List[bytes], session_id: str, audio_format="opus"
) -> Tuple[Optional[str], Optional[str]]:
"""将语音数据转换为文本"""
file_path = None
temp_path = None
try:
if audio_format == "pcm":
pcm_data = opus_data
else:
pcm_data = self.decode_opus(opus_data)
combined_pcm_data = b"".join(pcm_data)
free_space = shutil.disk_usage(self.output_dir).free
if free_space < len(combined_pcm_data) * 2:
raise OSError("磁盘空间不足")
if self.requires_file() and self.prefers_temp_file():
temp_path = self.build_temp_file(combined_pcm_data)
if (hasattr(self, "delete_audio_file") and not self.delete_audio_file) or (
self.requires_file() and not self.prefers_temp_file()
):
file_path = self.save_audio_to_file(pcm_data, session_id)
if len(combined_pcm_data) == 0:
artifacts = None
else:
artifacts = ASRProviderBase.AudioArtifacts(
pcm_frames=pcm_data,
pcm_bytes=combined_pcm_data,
file_path=file_path,
temp_path=temp_path,
)
text, _ = await self.speech_to_text(
opus_data, session_id, audio_format, artifacts
)
return text, file_path
except OSError as e:
logger.bind(tag=TAG).error(f"文件操作错误: {e}")
return None, None
except Exception as e:
logger.bind(tag=TAG).error(f"语音识别失败: {e}")
return None, None
finally:
try:
if temp_path and os.path.exists(temp_path):
os.unlink(temp_path)
if (
hasattr(self, "delete_audio_file")
and self.delete_audio_file
and file_path
and os.path.exists(file_path)
):
os.remove(file_path)
except Exception as e:
logger.bind(tag=TAG).error(f"文件清理失败: {e}")
@abstractmethod
async def speech_to_text(
self,
opus_data: List[bytes],
session_id: str,
audio_format="opus",
artifacts: Optional[AudioArtifacts] = None,
) -> Tuple[Optional[str], Optional[str]]:
"""将语音数据转换为文本
:param opus_data: 输入的Opus音频数据
:param session_id: 会话ID
:param audio_format: 音频格式,默认"opus"
:param artifacts: 音频工件,包含PCM数据、文件路径等
:return: 识别结果文本和文件路径(如果有)
"""
pass
@staticmethod
@@ -235,23 +349,23 @@ class ASRProviderBase(ABC):
decoder = opuslib_next.Decoder(16000, 1)
pcm_data = []
buffer_size = 960 # 每次处理960个采样点 (60ms at 16kHz)
for i, opus_packet in enumerate(opus_data):
try:
if not opus_packet or len(opus_packet) == 0:
continue
pcm_frame = decoder.decode(opus_packet, buffer_size)
if pcm_frame and len(pcm_frame) > 0:
pcm_data.append(pcm_frame)
except opuslib_next.OpusError as e:
logger.bind(tag=TAG).warning(f"Opus解码错误,跳过数据包 {i}: {e}")
except Exception as e:
logger.bind(tag=TAG).error(f"音频处理错误,数据包 {i}: {e}")
return pcm_data
except Exception as e:
logger.bind(tag=TAG).error(f"音频解码过程发生错误: {e}")
return []
@@ -232,24 +232,13 @@ class ASRProvider(ASRProviderBase):
yield data[offset:data_len], True
async def speech_to_text(
self, opus_data: List[bytes], session_id: str, audio_format="opus"
self, opus_data: List[bytes], session_id: str, audio_format="opus", artifacts=None
) -> Tuple[Optional[str], Optional[str]]:
"""将语音数据转换为文本"""
file_path = None
try:
# 合并所有opus数据包
if audio_format == "pcm":
pcm_data = opus_data
else:
pcm_data = self.decode_opus(opus_data)
combined_pcm_data = b"".join(pcm_data)
# 判断是否保存为WAV文件
if self.delete_audio_file:
pass
else:
file_path = self.save_audio_to_file(pcm_data, session_id)
if artifacts is None:
return "", None
# 直接使用PCM数据
# 计算分段大小 (单声道, 16bit, 16kHz采样率)
@@ -258,14 +247,14 @@ class ASRProvider(ASRProviderBase):
# 语音识别
start_time = time.time()
text = await self._send_request(combined_pcm_data, segment_size)
text = await self._send_request(artifacts.pcm_bytes, segment_size)
if text:
logger.bind(tag=TAG).debug(
f"语音识别耗时: {time.time() - start_time:.3f}s | 结果: {text}"
)
return text, file_path
return "", file_path
return text, artifacts.file_path
return "", artifacts.file_path
except Exception as e:
logger.bind(tag=TAG).error(f"语音识别失败: {e}", exc_info=True)
return "", file_path
return "", None
@@ -60,17 +60,8 @@ class ASRProvider(ASRProviderBase):
await super().open_audio_channels(conn)
async def receive_audio(self, conn, audio, audio_have_voice):
conn.asr_audio.append(audio)
conn.asr_audio = conn.asr_audio[-10:]
# 存储音频数据
if not hasattr(conn, 'asr_audio_for_voiceprint'):
conn.asr_audio_for_voiceprint = []
conn.asr_audio_for_voiceprint.append(audio)
# 当没有音频数据时处理完整语音片段
if conn.client_listen_mode != "manual" and not audio and len(conn.asr_audio_for_voiceprint) > 0:
await self.handle_voice_stop(conn, conn.asr_audio_for_voiceprint)
conn.asr_audio_for_voiceprint = []
# 先调用父类方法处理基础逻辑
await super().receive_audio(conn, audio, audio_have_voice)
# 如果本次有声音,且之前没有建立连接
if audio_have_voice and self.asr_ws is None and not self.is_processing:
@@ -164,7 +155,7 @@ class ASRProvider(ASRProviderBase):
try:
while self.asr_ws and not conn.stop_event.is_set():
# 获取当前连接的音频数据
audio_data = getattr(conn, 'asr_audio_for_voiceprint', [])
audio_data = conn.asr_audio
try:
response = await self.asr_ws.recv()
result = self.parse_response(response)
@@ -189,7 +180,6 @@ class ASRProvider(ASRProviderBase):
):
logger.bind(tag=TAG).error(f"识别文本:空")
self.text = ""
conn.reset_vad_states()
if len(audio_data) > 15: # 确保有足够音频数据
await self.handle_voice_stop(conn, audio_data)
break
@@ -200,12 +190,9 @@ class ASRProvider(ASRProviderBase):
if self.enable_multilingual:
continue
if conn.client_listen_mode == "manual" and conn.client_voice_stop and len(audio_data) > 0:
if conn.client_listen_mode == "manual" and conn.client_voice_stop and len(audio_data) > 15:
logger.bind(tag=TAG).debug("消息结束收到停止信号,触发处理")
await self.handle_voice_stop(conn, audio_data)
# 清理音频缓存
conn.asr_audio.clear()
conn.reset_vad_states()
break
for utterance in utterances:
@@ -226,14 +213,10 @@ class ASRProvider(ASRProviderBase):
if conn.client_voice_stop and len(audio_data) > 0:
logger.bind(tag=TAG).debug("消息中途收到停止信号,触发处理")
await self.handle_voice_stop(conn, audio_data)
# 清理音频缓存
conn.asr_audio.clear()
conn.reset_vad_states()
break
else:
# 自动模式下直接覆盖
self.text = current_text
conn.reset_vad_states()
if len(audio_data) > 15: # 确保有足够音频数据
await self.handle_voice_stop(conn, audio_data)
break
@@ -262,11 +245,8 @@ class ASRProvider(ASRProviderBase):
await self.asr_ws.close()
self.asr_ws = None
self.is_processing = False
if conn:
if hasattr(conn, 'asr_audio_for_voiceprint'):
conn.asr_audio_for_voiceprint = []
if hasattr(conn, 'asr_audio'):
conn.asr_audio = []
# 重置所有音频相关状态
conn.reset_audio_states()
def stop_ws_connection(self):
if self.asr_ws:
@@ -408,7 +388,7 @@ class ASRProvider(ASRProviderBase):
logger.bind(tag=TAG).error(f"原始响应数据: {res.hex()}")
raise
async def speech_to_text(self, opus_data, session_id, audio_format):
async def speech_to_text(self, opus_data, session_id, audio_format, artifacts=None):
result = self.text
self.text = "" # 清空text
return result, None
@@ -435,11 +415,3 @@ class ASRProvider(ASRProviderBase):
logger.bind(tag=TAG).debug("Doubao decoder resources released")
except Exception as e:
logger.bind(tag=TAG).debug(f"释放Doubao decoder资源时出错: {e}")
# 清理所有连接的音频缓冲区
if hasattr(self, '_connections'):
for conn in self._connections.values():
if hasattr(conn, 'asr_audio_for_voiceprint'):
conn.asr_audio_for_voiceprint = []
if hasattr(conn, 'asr_audio'):
conn.asr_audio = []
@@ -64,39 +64,21 @@ class ASRProvider(ASRProviderBase):
)
async def speech_to_text(
self, opus_data: List[bytes], session_id: str, audio_format="opus"
self, opus_data: List[bytes], session_id: str, audio_format="opus", artifacts=None
) -> Tuple[Optional[str], Optional[str]]:
"""语音转文本主处理逻辑"""
file_path = None
retry_count = 0
while retry_count < MAX_RETRIES:
try:
# 合并所有opus数据包
if audio_format == "pcm":
pcm_data = opus_data
else:
pcm_data = self.decode_opus(opus_data)
combined_pcm_data = b"".join(pcm_data)
# 检查磁盘空间
if not self.delete_audio_file:
free_space = shutil.disk_usage(self.output_dir).free
if free_space < len(combined_pcm_data) * 2: # 预留2倍空间
raise OSError("磁盘空间不足")
# 判断是否保存为WAV文件
if self.delete_audio_file:
pass
else:
file_path = self.save_audio_to_file(pcm_data, session_id)
if artifacts is None:
return "", None
# 语音识别 - 使用线程池避免阻塞事件循环
start_time = time.time()
result = await asyncio.to_thread(
self.model.generate,
input=combined_pcm_data,
input=artifacts.pcm_bytes,
cache={},
language="auto",
use_itn=True,
@@ -107,7 +89,7 @@ class ASRProvider(ASRProviderBase):
f"语音识别耗时: {time.time() - start_time:.3f}s | 结果: {text['content']}"
)
return text, file_path
return text, artifacts.file_path
except OSError as e:
retry_count += 1
@@ -115,7 +97,7 @@ class ASRProvider(ASRProviderBase):
logger.bind(tag=TAG).error(
f"语音识别失败(已重试{retry_count}次): {e}", exc_info=True
)
return "", file_path
return "", None
logger.bind(tag=TAG).warning(
f"语音识别失败,正在重试({retry_count}/{MAX_RETRIES}: {e}"
)
@@ -123,15 +105,4 @@ class ASRProvider(ASRProviderBase):
except Exception as e:
logger.bind(tag=TAG).error(f"语音识别失败: {e}", exc_info=True)
return "", file_path
finally:
# 文件清理逻辑
if self.delete_audio_file and file_path and os.path.exists(file_path):
try:
os.remove(file_path)
logger.bind(tag=TAG).debug(f"已删除临时音频文件: {file_path}")
except Exception as e:
logger.bind(tag=TAG).error(
f"文件删除失败: {file_path} | 错误: {e}"
)
return "", None
@@ -101,7 +101,7 @@ class ASRProvider(ASRProviderBase):
logger.bind(tag=TAG).debug(f"Sent end message: {end_message}")
async def speech_to_text(
self, opus_data: List[bytes], session_id: str, audio_format="opus"
self, opus_data: List[bytes], session_id: str, audio_format="opus", artifacts=None
) -> Tuple[Optional[str], Optional[str]]:
"""
Convert speech data to text using FunASR.
@@ -109,18 +109,9 @@ class ASRProvider(ASRProviderBase):
:param session_id: Unique session identifier.
:return: Tuple containing recognized text and optional timestamp.
"""
file_path = None
if audio_format == "pcm":
pcm_data = opus_data
else:
pcm_data = self.decode_opus(opus_data)
combined_pcm_data = b"".join(pcm_data)
# 判断是否保存为WAV文件
if self.delete_audio_file:
pass
else:
file_path = self.save_audio_to_file(pcm_data, session_id)
if artifacts is None:
return "", None
auth_header = {"Authorization": "Bearer; {}".format(self.api_key)}
async with websockets.connect(
self.uri,
@@ -132,7 +123,7 @@ class ASRProvider(ASRProviderBase):
try:
# Use asyncio to handle WebSocket communication
send_task = asyncio.create_task(
self._send_data(ws, combined_pcm_data, session_id)
self._send_data(ws, artifacts.pcm_bytes, session_id)
)
receive_task = asyncio.create_task(self._receive_responses(ws))
@@ -161,14 +152,14 @@ class ASRProvider(ASRProviderBase):
result = lang_tag_filter(result)
return (
result,
file_path,
artifacts.file_path,
) # Return the recognized text and timestamp (if any)
except websockets.exceptions.ConnectionClosed as e:
logger.bind(tag=TAG).error(f"WebSocket connection closed: {e}")
return "", file_path
return "", artifacts.file_path
except Exception as e:
logger.bind(tag=TAG).error(
f"Error during speech-to-text conversion: {e}", exc_info=True
)
return "", file_path
return "", artifacts.file_path
@@ -21,20 +21,16 @@ class ASRProvider(ASRProviderBase):
os.makedirs(self.output_dir, exist_ok=True)
async def speech_to_text(self, opus_data: List[bytes], session_id: str, audio_format="opus") -> Tuple[Optional[str], Optional[str]]:
def requires_file(self) -> bool:
return True
async def speech_to_text(self, opus_data: List[bytes], session_id: str, audio_format="opus", artifacts=None) -> Tuple[Optional[str], Optional[str]]:
file_path = None
try:
start_time = time.time()
if audio_format == "pcm":
pcm_data = opus_data
else:
pcm_data = self.decode_opus(opus_data)
file_path = self.save_audio_to_file(pcm_data, session_id)
logger.bind(tag=TAG).debug(
f"音频文件保存耗时: {time.time() - start_time:.3f}s | 路径: {file_path}"
)
if artifacts is None:
return "", None
file_path = artifacts.file_path
logger.bind(tag=TAG).info(f"file path: {file_path}")
headers = {
"Authorization": f"Bearer {self.api_key}",
@@ -71,12 +67,4 @@ class ASRProvider(ASRProviderBase):
except Exception as e:
logger.bind(tag=TAG).error(f"语音识别失败: {e}")
return "", None
finally:
# 文件清理逻辑
if self.delete_audio_file and file_path and os.path.exists(file_path):
try:
os.remove(file_path)
logger.bind(tag=TAG).debug(f"已删除临时音频文件: {file_path}")
except Exception as e:
logger.bind(tag=TAG).error(f"文件删除失败: {file_path} | 错误: {e}")
@@ -1,5 +1,4 @@
import os
import tempfile
from typing import Optional, Tuple, List
import dashscope
from config.logger import setup_logging
@@ -35,56 +34,25 @@ class ASRProvider(ASRProviderBase):
# 确保输出目录存在
os.makedirs(self.output_dir, exist_ok=True)
def _prepare_audio_file(self, pcm_data: bytes) -> str:
"""将PCM数据转换为WAV文件并返回文件路径"""
try:
import wave
# 创建临时WAV文件
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as temp_file:
temp_path = temp_file.name
# 写入WAV格式
with wave.open(temp_path, 'wb') as wav_file:
wav_file.setnchannels(1) # 单声道
wav_file.setsampwidth(2) # 16位
wav_file.setframerate(16000) # 16kHz采样率
wav_file.writeframes(pcm_data)
return temp_path
except Exception as e:
logger.bind(tag=tag).error(f"音频文件准备失败: {e}")
return None
def prefers_temp_file(self) -> bool:
return True
def requires_file(self) -> bool:
return True
async def speech_to_text(
self, opus_data: List[bytes], session_id: str, audio_format="opus"
self, opus_data: List[bytes], session_id: str, audio_format="opus", artifacts=None
) -> Tuple[Optional[str], Optional[str]]:
"""将语音数据转换为文本"""
temp_file_path = None
file_path = None
try:
# 解码音频数据
if audio_format == "pcm":
pcm_data = opus_data
else:
pcm_data = self.decode_opus(opus_data)
combined_pcm_data = b"".join(pcm_data)
if len(combined_pcm_data) == 0:
logger.bind(tag=tag).warning("音频数据为空")
if artifacts is None:
return "", None
# 准备音频文件
temp_file_path = self._prepare_audio_file(combined_pcm_data)
temp_file_path = artifacts.temp_path
file_path = artifacts.file_path
if not temp_file_path:
return "", None
# 保存音频文件(如果需要)
if not self.delete_audio_file:
file_path = self.save_audio_to_file(pcm_data, session_id)
return "", file_path
# 构造请求消息
messages = [
{
@@ -141,11 +109,3 @@ class ASRProvider(ASRProviderBase):
except Exception as e:
logger.bind(tag=tag).error(f"语音识别失败: {e}")
return "", file_path
finally:
# 清理临时文件
if temp_file_path and os.path.exists(temp_file_path):
try:
os.unlink(temp_file_path)
except Exception as e:
logger.bind(tag=tag).warning(f"清理临时文件失败: {e}")
@@ -120,24 +120,19 @@ class ASRProvider(ASRProviderBase):
samples_float32 = samples_float32 / 32768
return samples_float32, f.getframerate()
def requires_file(self) -> bool:
return True
async def speech_to_text(
self, opus_data: List[bytes], session_id: str, audio_format="opus"
self, opus_data: List[bytes], session_id: str, audio_format="opus", artifacts=None
) -> Tuple[Optional[str], Optional[str]]:
"""语音转文本主处理逻辑"""
file_path = None
try:
# 保存音频文件
start_time = time.time()
if audio_format == "pcm":
pcm_data = opus_data
else:
pcm_data = self.decode_opus(opus_data)
file_path = self.save_audio_to_file(pcm_data, session_id)
logger.bind(tag=TAG).debug(
f"音频文件保存耗时: {time.time() - start_time:.3f}s | 路径: {file_path}"
)
if artifacts is None:
return "", None
file_path = artifacts.file_path
# 语音识别
start_time = time.time()
s = self.model.create_stream()
samples, sample_rate = self.read_wave(file_path)
@@ -153,11 +148,3 @@ class ASRProvider(ASRProviderBase):
except Exception as e:
logger.bind(tag=TAG).error(f"语音识别失败: {e}", exc_info=True)
return "", file_path
finally:
# 文件清理逻辑
if self.delete_audio_file and file_path and os.path.exists(file_path):
try:
os.remove(file_path)
logger.bind(tag=TAG).debug(f"已删除临时音频文件: {file_path}")
except Exception as e:
logger.bind(tag=TAG).error(f"文件删除失败: {file_path} | 错误: {e}")
@@ -32,35 +32,24 @@ class ASRProvider(ASRProviderBase):
os.makedirs(self.output_dir, exist_ok=True)
async def speech_to_text(
self, opus_data: List[bytes], session_id: str, audio_format="opus"
self, opus_data: List[bytes], session_id: str, audio_format="opus", artifacts=None
) -> Tuple[Optional[str], Optional[str]]:
"""将语音数据转换为文本"""
if not opus_data:
logger.bind(tag=TAG).warning("音频数据为空!")
return None, None
file_path = None
try:
# 检查配置是否已设置
if not self.secret_id or not self.secret_key:
logger.bind(tag=TAG).error("腾讯云语音识别配置未设置,无法进行识别")
return None, file_path
return None, None
# 将Opus音频数据解码为PCM
if audio_format == "pcm":
pcm_data = opus_data
else:
pcm_data = self.decode_opus(opus_data)
combined_pcm_data = b"".join(pcm_data)
# 判断是否保存为WAV文件
if self.delete_audio_file:
pass
else:
self.save_audio_to_file(pcm_data, session_id)
if artifacts is None:
return "", None
# 将音频数据转换为Base64编码
base64_audio = base64.b64encode(combined_pcm_data).decode("utf-8")
base64_audio = base64.b64encode(artifacts.pcm_bytes).decode("utf-8")
# 构建请求体
request_body = self._build_request_body(base64_audio)
@@ -77,11 +66,11 @@ class ASRProvider(ASRProviderBase):
f"腾讯云语音识别耗时: {time.time() - start_time:.3f}s | 结果: {result}"
)
return result, file_path
return result, artifacts.file_path
except Exception as e:
logger.bind(tag=TAG).error(f"处理音频时发生错误!{e}", exc_info=True)
return None, file_path
return None, None
def _build_request_body(self, base64_audio: str) -> str:
"""构建请求体"""
+7 -30
View File
@@ -44,36 +44,21 @@ class ASRProvider(ASRProviderBase):
raise
async def speech_to_text(
self, audio_data: List[bytes], session_id: str, audio_format: str = "opus"
self, opus_data: List[bytes], session_id: str, audio_format="opus", artifacts=None
) -> Tuple[Optional[str], Optional[str]]:
"""将语音数据转换为文本"""
file_path = None
try:
# 检查模型是否加载成功
if not self.model:
logger.bind(tag=TAG).error("VOSK模型未加载,无法进行识别")
return "", None
# 解码音频(如果原始格式是Opus
if audio_format == "pcm":
pcm_data = audio_data
else:
pcm_data = self.decode_opus(audio_data)
if not pcm_data:
logger.bind(tag=TAG).warning("解码后的PCM数据为空,无法进行识别")
if artifacts is None:
return "", None
# 合并PCM数据
combined_pcm_data = b"".join(pcm_data)
if len(combined_pcm_data) == 0:
if not artifacts.pcm_bytes:
logger.bind(tag=TAG).warning("合并后的PCM数据为空")
return "", None
# 判断是否保存为WAV文件
if not self.delete_audio_file:
file_path = self.save_audio_to_file(pcm_data, session_id)
start_time = time.time()
@@ -81,8 +66,8 @@ class ASRProvider(ASRProviderBase):
chunk_size = 2000
text_result = ""
for i in range(0, len(combined_pcm_data), chunk_size):
chunk = combined_pcm_data[i:i+chunk_size]
for i in range(0, len(artifacts.pcm_bytes), chunk_size):
chunk = artifacts.pcm_bytes[i:i+chunk_size]
if self.recognizer.AcceptWaveform(chunk):
result = json.loads(self.recognizer.Result())
text = result.get('text', '')
@@ -99,16 +84,8 @@ class ASRProvider(ASRProviderBase):
f"VOSK语音识别耗时: {time.time() - start_time:.3f}s | 结果: {text_result.strip()}"
)
return text_result.strip(), file_path
return text_result.strip(), artifacts.file_path
except Exception as e:
logger.bind(tag=TAG).error(f"VOSK语音识别失败: {e}")
return "", None
finally:
# 文件清理逻辑
if self.delete_audio_file and file_path and os.path.exists(file_path):
try:
os.remove(file_path)
logger.bind(tag=TAG).debug(f"已删除临时音频文件: {file_path}")
except Exception as e:
logger.bind(tag=TAG).error(f"文件删除失败: {file_path} | 错误: {e}")
@@ -101,11 +101,6 @@ class ASRProvider(ASRProviderBase):
# 先调用父类方法处理基础逻辑
await super().receive_audio(conn, audio, audio_have_voice)
# 存储音频数据用于声纹识别
if not hasattr(conn, "asr_audio_for_voiceprint"):
conn.asr_audio_for_voiceprint = []
conn.asr_audio_for_voiceprint.append(audio)
# 如果本次有声音,且之前没有建立连接
if audio_have_voice and self.asr_ws is None and not self.is_processing:
try:
@@ -231,14 +226,8 @@ class ASRProvider(ASRProviderBase):
self.text += w
if status == 2:
if conn.client_listen_mode == "manual":
audio_data = getattr(conn, 'asr_audio_for_voiceprint', [])
if len(audio_data) > 0:
logger.bind(tag=TAG).debug("收到最终识别结果,触发处理")
await self.handle_voice_stop(conn, audio_data)
# 清理音频缓存
conn.asr_audio.clear()
conn.reset_vad_states()
logger.bind(tag=TAG).debug("收到最终识别结果,触发处理")
await self.handle_voice_stop(conn, conn.asr_audio)
break
except asyncio.TimeoutError:
@@ -262,13 +251,7 @@ class ASRProvider(ASRProviderBase):
finally:
# 清理连接资源
await self._cleanup()
# 清理连接的音频缓存
if conn:
if hasattr(conn, "asr_audio_for_voiceprint"):
conn.asr_audio_for_voiceprint = []
if hasattr(conn, "asr_audio"):
conn.asr_audio = []
conn.reset_audio_states()
async def handle_voice_stop(self, conn, asr_audio_task: List[bytes]):
"""处理语音停止,发送最后一帧并处理识别结果"""
@@ -334,7 +317,7 @@ class ASRProvider(ASRProviderBase):
logger.bind(tag=TAG).debug("ASR会话清理完成")
async def speech_to_text(self, opus_data, session_id, audio_format):
async def speech_to_text(self, opus_data, session_id, audio_format, artifacts=None):
"""获取识别结果"""
result = self.text
self.text = ""
@@ -363,10 +346,3 @@ class ASRProvider(ASRProviderBase):
except Exception as e:
logger.bind(tag=TAG).debug(f"释放Xunfei decoder资源时出错: {e}")
# 清理所有连接的音频缓冲区
if hasattr(self, "_connections"):
for conn in self._connections.values():
if hasattr(conn, "asr_audio_for_voiceprint"):
conn.asr_audio_for_voiceprint = []
if hasattr(conn, "asr_audio"):
conn.asr_audio = []
@@ -2,11 +2,14 @@ from typing import List, Dict
from ..base import IntentProviderBase
from plugins_func.functions.play_music import initialize_music_handler
from config.logger import setup_logging
from core.utils.util import get_system_error_response
import re
import json
import hashlib
import time
TAG = __name__
logger = setup_logging()
@@ -115,12 +118,16 @@ class IntentProvider(IntentProviderBase):
return prompt
def replyResult(self, text: str, original_text: str):
llm_result = self.llm.response_no_stream(
system_prompt=text,
user_prompt="请根据以上内容,像人类一样说话的口吻回复用户,要求简洁,请直接返回结果。用户现在说:"
+ original_text,
)
return llm_result
try:
llm_result = self.llm.response_no_stream(
system_prompt=text,
user_prompt="请根据以上内容,像人类一样说话的口吻回复用户,要求简洁,请直接返回结果。用户现在说:"
+ original_text,
)
return llm_result
except Exception as e:
logger.bind(tag=TAG).error(f"Error in generating reply result: {e}")
return get_system_error_response(self.config)
async def detect_intent(self, conn, dialogue_history: List[Dict], text: str) -> str:
if not self.llm:
@@ -194,9 +201,13 @@ class IntentProvider(IntentProviderBase):
llm_start_time = time.time()
logger.bind(tag=TAG).debug(f"开始LLM意图识别调用, 模型: {model_info}")
intent = self.llm.response_no_stream(
system_prompt=prompt_music, user_prompt=user_prompt
)
try:
intent = self.llm.response_no_stream(
system_prompt=prompt_music, user_prompt=user_prompt
)
except Exception as e:
logger.bind(tag=TAG).error(f"Error in intent detection LLM call: {e}")
return '{"function_call": {"name": "continue_chat"}}'
# 记录LLM调用完成时间
llm_time = time.time() - llm_start_time
@@ -21,83 +21,78 @@ class LLMProvider(LLMProviderBase):
check_model_key("AliBLLLM", self.api_key)
def response(self, session_id, dialogue):
try:
# 处理dialogue
if self.is_No_prompt:
dialogue.pop(0)
logger.bind(tag=TAG).debug(
f"【阿里百练API服务】处理后的dialogue: {dialogue}"
)
# 构造调用参数
call_params = {
"api_key": self.api_key,
"app_id": self.app_id,
"session_id": session_id,
"messages": dialogue,
# 开启SDK原生流式
"stream": True,
}
if self.memory_id != False:
# 百练memory需要prompt参数
prompt = dialogue[-1].get("content")
call_params["memory_id"] = self.memory_id
call_params["prompt"] = prompt
logger.bind(tag=TAG).debug(
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)
# 流式处理(SDK在stream=True时返回可迭代对象;否则返回单次响应对象)
# 处理dialogue
if self.is_No_prompt:
dialogue.pop(0)
logger.bind(tag=TAG).debug(
f"【阿里百练API服务】构造参数: {dict(call_params, api_key='***')}"
f"【阿里百练API服务】处理后的dialogue: {dialogue}"
)
last_text = ""
try:
for resp in responses:
if resp.status_code != HTTPStatus.OK:
logger.bind(tag=TAG).error(
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
# 构造调用参数
call_params = {
"api_key": self.api_key,
"app_id": self.app_id,
"session_id": session_id,
"messages": dialogue,
# 开启SDK原生流式
"stream": True,
}
if self.memory_id != False:
# 百练memory需要prompt参数
prompt = dialogue[-1].get("content")
call_params["memory_id"] = self.memory_id
call_params["prompt"] = prompt
logger.bind(tag=TAG).debug(
f"【阿里百练API服务】处理后的prompt: {prompt}"
)
except Exception as e:
logger.bind(tag=TAG).error(f"【阿里百练API服务】响应异常: {e}")
yield "【LLM服务响应异常】"
# 可选地设置自定义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)
# 流式处理(SDK在stream=True时返回可迭代对象;否则返回单次响应对象)
logger.bind(tag=TAG).debug(
f"【阿里百练API服务】构造参数: {dict(call_params, api_key='***')}"
)
last_text = ""
try:
for resp in responses:
if resp.status_code != HTTPStatus.OK:
logger.bind(tag=TAG).error(
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
def response_with_functions(self, session_id, dialogue, functions=None):
# 阿里百练当前未支持原生的 function call。为保持兼容,这里回退到普通文本流式输出。
+9 -14
View File
@@ -11,20 +11,15 @@ class LLMProviderBase(ABC):
pass
def response_no_stream(self, system_prompt, user_prompt, **kwargs):
try:
# 构造对话格式
dialogue = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}
]
result = ""
for part in self.response("", dialogue, **kwargs):
result += part
return result
except Exception as e:
logger.bind(tag=TAG).error(f"Error in Ollama response generation: {e}")
return "【LLM服务响应异常】"
# 构造对话格式
dialogue = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}
]
result = ""
for part in self.response("", dialogue, **kwargs):
result += part
return result
def response_with_functions(self, session_id, dialogue, functions=None):
"""
@@ -20,76 +20,71 @@ class LLMProvider(LLMProviderBase):
logger.bind(tag=TAG).error(model_key_msg)
def response(self, session_id, dialogue, **kwargs):
try:
# 取最后一条用户消息
last_msg = next(m for m in reversed(dialogue) if m["role"] == "user")
conversation_id = self.session_conversation_map.get(session_id)
# 取最后一条用户消息
last_msg = next(m for m in reversed(dialogue) if m["role"] == "user")
conversation_id = self.session_conversation_map.get(session_id)
# 发起流式请求
# 发起流式请求
if self.mode == "chat-messages":
request_json = {
"query": last_msg["content"],
"response_mode": "streaming",
"user": session_id,
"inputs": {},
"conversation_id": conversation_id,
}
elif self.mode == "workflows/run":
request_json = {
"inputs": {"query": last_msg["content"]},
"response_mode": "streaming",
"user": session_id,
}
elif self.mode == "completion-messages":
request_json = {
"inputs": {"query": last_msg["content"]},
"response_mode": "streaming",
"user": session_id,
}
with requests.post(
f"{self.base_url}/{self.mode}",
headers={"Authorization": f"Bearer {self.api_key}"},
json=request_json,
stream=True,
) as r:
if self.mode == "chat-messages":
request_json = {
"query": last_msg["content"],
"response_mode": "streaming",
"user": session_id,
"inputs": {},
"conversation_id": conversation_id,
}
for line in r.iter_lines():
if line.startswith(b"data: "):
event = json.loads(line[6:])
# 如果没有找到conversation_id,则获取此次conversation_id
if not conversation_id:
conversation_id = event.get("conversation_id")
self.session_conversation_map[session_id] = (
conversation_id # 更新映射
)
# 过滤 message_replace 事件,此事件会全量推一次
if event.get("event") != "message_replace" and event.get(
"answer"
):
yield event["answer"]
elif self.mode == "workflows/run":
request_json = {
"inputs": {"query": last_msg["content"]},
"response_mode": "streaming",
"user": session_id,
}
for line in r.iter_lines():
if line.startswith(b"data: "):
event = json.loads(line[6:])
if event.get("event") == "workflow_finished":
if event["data"]["status"] == "succeeded":
yield event["data"]["outputs"]["answer"]
else:
yield "【服务响应异常】"
elif self.mode == "completion-messages":
request_json = {
"inputs": {"query": last_msg["content"]},
"response_mode": "streaming",
"user": session_id,
}
with requests.post(
f"{self.base_url}/{self.mode}",
headers={"Authorization": f"Bearer {self.api_key}"},
json=request_json,
stream=True,
) as r:
if self.mode == "chat-messages":
for line in r.iter_lines():
if line.startswith(b"data: "):
event = json.loads(line[6:])
# 如果没有找到conversation_id,则获取此次conversation_id
if not conversation_id:
conversation_id = event.get("conversation_id")
self.session_conversation_map[session_id] = (
conversation_id # 更新映射
)
# 过滤 message_replace 事件,此事件会全量推一次
if event.get("event") != "message_replace" and event.get(
"answer"
):
yield event["answer"]
elif self.mode == "workflows/run":
for line in r.iter_lines():
if line.startswith(b"data: "):
event = json.loads(line[6:])
if event.get("event") == "workflow_finished":
if event["data"]["status"] == "succeeded":
yield event["data"]["outputs"]["answer"]
else:
yield "【服务响应异常】"
elif self.mode == "completion-messages":
for line in r.iter_lines():
if line.startswith(b"data: "):
event = json.loads(line[6:])
# 过滤 message_replace 事件,此事件会全量推一次
if event.get("event") != "message_replace" and event.get(
"answer"
):
yield event["answer"]
except Exception as e:
logger.bind(tag=TAG).error(f"Error in response generation: {e}")
yield "【服务响应异常】"
for line in r.iter_lines():
if line.startswith(b"data: "):
event = json.loads(line[6:])
# 过滤 message_replace 事件,此事件会全量推一次
if event.get("event") != "message_replace" and event.get(
"answer"
):
yield event["answer"]
def response_with_functions(self, session_id, dialogue, functions=None):
if len(dialogue) == 2 and functions is not None and len(functions) > 0:
@@ -19,53 +19,48 @@ class LLMProvider(LLMProviderBase):
logger.bind(tag=TAG).error(model_key_msg)
def response(self, session_id, dialogue, **kwargs):
try:
# 取最后一条用户消息
last_msg = next(m for m in reversed(dialogue) if m["role"] == "user")
# 取最后一条用户消息
last_msg = next(m for m in reversed(dialogue) if m["role"] == "user")
# 发起流式请求
with requests.post(
f"{self.base_url}/chat/completions",
headers={"Authorization": f"Bearer {self.api_key}"},
json={
"stream": True,
"chatId": session_id,
"detail": self.detail,
"variables": self.variables,
"messages": [{"role": "user", "content": last_msg["content"]}],
},
stream=True,
) as r:
for line in r.iter_lines():
if line:
try:
if line.startswith(b"data: "):
if line[6:].decode("utf-8") == "[DONE]":
break
# 发起流式请求
with requests.post(
f"{self.base_url}/chat/completions",
headers={"Authorization": f"Bearer {self.api_key}"},
json={
"stream": True,
"chatId": session_id,
"detail": self.detail,
"variables": self.variables,
"messages": [{"role": "user", "content": last_msg["content"]}],
},
stream=True,
) as r:
for line in r.iter_lines():
if line:
try:
if line.startswith(b"data: "):
if line[6:].decode("utf-8") == "[DONE]":
break
data = json.loads(line[6:])
if "choices" in data and len(data["choices"]) > 0:
delta = data["choices"][0].get("delta", {})
if (
delta
and "content" in delta
and delta["content"] is not None
):
content = delta["content"]
if "<think>" in content:
continue
if "</think>" in content:
continue
yield content
data = json.loads(line[6:])
if "choices" in data and len(data["choices"]) > 0:
delta = data["choices"][0].get("delta", {})
if (
delta
and "content" in delta
and delta["content"] is not None
):
content = delta["content"]
if "<think>" in content:
continue
if "</think>" in content:
continue
yield content
except json.JSONDecodeError as e:
continue
except Exception as e:
continue
except Exception as e:
logger.bind(tag=TAG).error(f"Error in response generation: {e}")
yield "【服务响应异常】"
except json.JSONDecodeError as e:
continue
except Exception as e:
continue
def response_with_functions(self, session_id, dialogue, functions=None):
logger.bind(tag=TAG).error(
@@ -15,55 +15,49 @@ class LLMProvider(LLMProviderBase):
self.api_url = f"{self.base_url}/api/conversation/process" # 拼接完整的 API URL
def response(self, session_id, dialogue, **kwargs):
try:
# home assistant语音助手自带意图,无需使用xiaozhi ai自带的,只需要把用户说的话传递给home assistant即可
# home assistant语音助手自带意图,无需使用xiaozhi ai自带的,只需要把用户说的话传递给home assistant即可
# 提取最后一个 role 为 'user' 的 content
input_text = None
if isinstance(dialogue, list): # 确保 dialogue 是一个列表
# 逆序遍历,找到最后一个 role 为 'user' 的消息
for message in reversed(dialogue):
if message.get("role") == "user": # 找到 role 为 'user' 的消息
input_text = message.get("content", "")
break # 找到后立即退出循环
# 提取最后一个 role 为 'user' 的 content
input_text = None
if isinstance(dialogue, list): # 确保 dialogue 是一个列表
# 逆序遍历,找到最后一个 role 为 'user' 的消息
for message in reversed(dialogue):
if message.get("role") == "user": # 找到 role 为 'user' 的消息
input_text = message.get("content", "")
break # 找到后立即退出循环
# 构造请求数据
payload = {
"text": input_text,
"agent_id": self.agent_id,
"conversation_id": session_id, # 使用 session_id 作为 conversation_id
}
# 设置请求头
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
# 构造请求数据
payload = {
"text": input_text,
"agent_id": self.agent_id,
"conversation_id": session_id, # 使用 session_id 作为 conversation_id
}
# 设置请求头
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
# 发起 POST 请求
response = requests.post(self.api_url, json=payload, headers=headers)
# 发起 POST 请求
response = requests.post(self.api_url, json=payload, headers=headers)
# 检查请求是否成功
response.raise_for_status()
# 检查请求是否成功
response.raise_for_status()
# 解析返回数据
data = response.json()
speech = (
data.get("response", {})
.get("speech", {})
.get("plain", {})
.get("speech", "")
)
# 解析返回数据
data = response.json()
speech = (
data.get("response", {})
.get("speech", {})
.get("plain", {})
.get("speech", "")
)
# 返回生成的内容
if speech:
yield speech
else:
logger.bind(tag=TAG).warning("API 返回数据中没有 speech 内容")
except RequestException as e:
logger.bind(tag=TAG).error(f"HTTP 请求错误: {e}")
except Exception as e:
logger.bind(tag=TAG).error(f"生成响应时出错: {e}")
# 返回生成的内容
if speech:
yield speech
else:
logger.bind(tag=TAG).warning("API 返回数据中没有 speech 内容")
def response_with_functions(self, session_id, dialogue, functions=None):
logger.bind(tag=TAG).error(
@@ -25,151 +25,141 @@ class LLMProvider(LLMProviderBase):
self.is_qwen3 = self.model_name and self.model_name.lower().startswith("qwen3")
def response(self, session_id, dialogue, **kwargs):
try:
# 如果是qwen3模型,在用户最后一条消息中添加/no_think指令
if self.is_qwen3:
# 复制对话列表,避免修改原始对话
dialogue_copy = dialogue.copy()
# 如果是qwen3模型,在用户最后一条消息中添加/no_think指令
if self.is_qwen3:
# 复制对话列表,避免修改原始对话
dialogue_copy = dialogue.copy()
# 找到最后一条用户消息
for i in range(len(dialogue_copy) - 1, -1, -1):
if dialogue_copy[i]["role"] == "user":
# 在用户消息前添加/no_think指令
dialogue_copy[i]["content"] = (
"/no_think " + dialogue_copy[i]["content"]
)
logger.bind(tag=TAG).debug(f"为qwen3模型添加/no_think指令")
break
# 使用修改后的对话
dialogue = dialogue_copy
responses = self.client.chat.completions.create(
model=self.model_name, messages=dialogue, stream=True
)
is_active = True
# 用于处理跨chunk的标签
buffer = ""
for chunk in responses:
try:
delta = (
chunk.choices[0].delta
if getattr(chunk, "choices", None)
else None
# 找到最后一条用户消息
for i in range(len(dialogue_copy) - 1, -1, -1):
if dialogue_copy[i]["role"] == "user":
# 在用户消息前添加/no_think指令
dialogue_copy[i]["content"] = (
"/no_think " + dialogue_copy[i]["content"]
)
content = delta.content if hasattr(delta, "content") else ""
logger.bind(tag=TAG).debug(f"为qwen3模型添加/no_think指令")
break
if content:
# 将内容添加到缓冲区
buffer += content
# 使用修改后的对话
dialogue = dialogue_copy
# 处理缓冲区中的标签
while "<think>" in buffer and "</think>" in buffer:
# 找到完整的<think></think>标签并移除
pre = buffer.split("<think>", 1)[0]
post = buffer.split("</think>", 1)[1]
buffer = pre + post
responses = self.client.chat.completions.create(
model=self.model_name, messages=dialogue, stream=True
)
is_active = True
# 用于处理跨chunk的标签
buffer = ""
# 处理只有开始标签的情况
if "<think>" in buffer:
is_active = False
buffer = buffer.split("<think>", 1)[0]
for chunk in responses:
try:
delta = (
chunk.choices[0].delta
if getattr(chunk, "choices", None)
else None
)
content = delta.content if hasattr(delta, "content") else ""
# 处理只有结束标签的情况
if "</think>" in buffer:
is_active = True
buffer = buffer.split("</think>", 1)[1]
if content:
# 将内容添加到缓冲区
buffer += content
# 如果当前处于活动状态且缓冲区有内容,则输出
if is_active and buffer:
yield buffer
buffer = "" # 清空缓冲区
# 处理缓冲区中的标签
while "<think>" in buffer and "</think>" in buffer:
# 找到完整的<think></think>标签并移除
pre = buffer.split("<think>", 1)[0]
post = buffer.split("</think>", 1)[1]
buffer = pre + post
except Exception as e:
logger.bind(tag=TAG).error(f"Error processing chunk: {e}")
# 处理只有开始标签的情况
if "<think>" in buffer:
is_active = False
buffer = buffer.split("<think>", 1)[0]
except Exception as e:
logger.bind(tag=TAG).error(f"Error in Ollama response generation: {e}")
yield "【Ollama服务响应异常】"
# 处理只有结束标签的情况
if "</think>" in buffer:
is_active = True
buffer = buffer.split("</think>", 1)[1]
# 如果当前处于活动状态且缓冲区有内容,则输出
if is_active and buffer:
yield buffer
buffer = "" # 清空缓冲区
except Exception as e:
logger.bind(tag=TAG).error(f"Error processing chunk: {e}")
def response_with_functions(self, session_id, dialogue, functions=None):
try:
# 如果是qwen3模型,在用户最后一条消息中添加/no_think指令
if self.is_qwen3:
# 复制对话列表,避免修改原始对话
dialogue_copy = dialogue.copy()
# 如果是qwen3模型,在用户最后一条消息中添加/no_think指令
if self.is_qwen3:
# 复制对话列表,避免修改原始对话
dialogue_copy = dialogue.copy()
# 找到最后一条用户消息
for i in range(len(dialogue_copy) - 1, -1, -1):
if dialogue_copy[i]["role"] == "user":
# 在用户消息前添加/no_think指令
dialogue_copy[i]["content"] = (
"/no_think " + dialogue_copy[i]["content"]
)
logger.bind(tag=TAG).debug(f"为qwen3模型添加/no_think指令")
break
# 使用修改后的对话
dialogue = dialogue_copy
stream = self.client.chat.completions.create(
model=self.model_name,
messages=dialogue,
stream=True,
tools=functions,
)
is_active = True
buffer = ""
for chunk in stream:
try:
delta = (
chunk.choices[0].delta
if getattr(chunk, "choices", None)
else None
)
content = delta.content if hasattr(delta, "content") else None
tool_calls = (
delta.tool_calls if hasattr(delta, "tool_calls") else None
# 找到最后一条用户消息
for i in range(len(dialogue_copy) - 1, -1, -1):
if dialogue_copy[i]["role"] == "user":
# 在用户消息前添加/no_think指令
dialogue_copy[i]["content"] = (
"/no_think " + dialogue_copy[i]["content"]
)
logger.bind(tag=TAG).debug(f"为qwen3模型添加/no_think指令")
break
# 如果是工具调用,直接传递
if tool_calls:
yield None, tool_calls
continue
# 使用修改后的对话
dialogue = dialogue_copy
# 处理文本内容
if content:
# 将内容添加到缓冲区
buffer += content
stream = self.client.chat.completions.create(
model=self.model_name,
messages=dialogue,
stream=True,
tools=functions,
)
# 处理缓冲区中的标签
while "<think>" in buffer and "</think>" in buffer:
# 找到完整的<think></think>标签并移除
pre = buffer.split("<think>", 1)[0]
post = buffer.split("</think>", 1)[1]
buffer = pre + post
is_active = True
buffer = ""
# 处理只有开始标签的情况
if "<think>" in buffer:
is_active = False
buffer = buffer.split("<think>", 1)[0]
for chunk in stream:
try:
delta = (
chunk.choices[0].delta
if getattr(chunk, "choices", None)
else None
)
content = delta.content if hasattr(delta, "content") else None
tool_calls = (
delta.tool_calls if hasattr(delta, "tool_calls") else None
)
# 处理只有结束标签的情况
if "</think>" in buffer:
is_active = True
buffer = buffer.split("</think>", 1)[1]
# 如果当前处于活动状态且缓冲区有内容,则输出
if is_active and buffer:
yield buffer, None
buffer = "" # 清空缓冲区
except Exception as e:
logger.bind(tag=TAG).error(f"Error processing function chunk: {e}")
# 如果是工具调用,直接传递
if tool_calls:
yield None, tool_calls
continue
except Exception as e:
logger.bind(tag=TAG).error(f"Error in Ollama function call: {e}")
yield f"【Ollama服务响应异常: {str(e)}", None
# 处理文本内容
if content:
# 将内容添加到缓冲区
buffer += content
# 处理缓冲区中的标签
while "<think>" in buffer and "</think>" in buffer:
# 找到完整的<think></think>标签并移除
pre = buffer.split("<think>", 1)[0]
post = buffer.split("</think>", 1)[1]
buffer = pre + post
# 处理只有开始标签的情况
if "<think>" in buffer:
is_active = False
buffer = buffer.split("<think>", 1)[0]
# 处理只有结束标签的情况
if "</think>" in buffer:
is_active = True
buffer = buffer.split("</think>", 1)[1]
# 如果当前处于活动状态且缓冲区有内容,则输出
if is_active and buffer:
yield buffer, None
buffer = "" # 清空缓冲区
except Exception as e:
logger.bind(tag=TAG).error(f"Error processing function chunk: {e}")
continue
@@ -56,87 +56,78 @@ class LLMProvider(LLMProviderBase):
return dialogue
def response(self, session_id, dialogue, **kwargs):
try:
dialogue = self.normalize_dialogue(dialogue)
dialogue = self.normalize_dialogue(dialogue)
request_params = {
"model": self.model_name,
"messages": dialogue,
"stream": True,
}
request_params = {
"model": self.model_name,
"messages": dialogue,
"stream": True,
}
# 添加可选参数,只有当参数不为None时才添加
optional_params = {
"max_tokens": kwargs.get("max_tokens", self.max_tokens),
"temperature": kwargs.get("temperature", self.temperature),
"top_p": kwargs.get("top_p", self.top_p),
"frequency_penalty": kwargs.get("frequency_penalty", self.frequency_penalty),
}
# 添加可选参数,只有当参数不为None时才添加
optional_params = {
"max_tokens": kwargs.get("max_tokens", self.max_tokens),
"temperature": kwargs.get("temperature", self.temperature),
"top_p": kwargs.get("top_p", self.top_p),
"frequency_penalty": kwargs.get("frequency_penalty", self.frequency_penalty),
}
for key, value in optional_params.items():
if value is not None:
request_params[key] = value
for key, value in optional_params.items():
if value is not None:
request_params[key] = value
responses = self.client.chat.completions.create(**request_params)
responses = self.client.chat.completions.create(**request_params)
is_active = True
for chunk in responses:
try:
delta = chunk.choices[0].delta if getattr(chunk, "choices", None) else None
content = getattr(delta, "content", "") if delta else ""
except IndexError:
content = ""
if content:
if "<think>" in content:
is_active = False
content = content.split("<think>")[0]
if "</think>" in content:
is_active = True
content = content.split("</think>")[-1]
if is_active:
yield content
except Exception as e:
logger.bind(tag=TAG).error(f"Error in response generation: {e}")
is_active = True
for chunk in responses:
try:
delta = chunk.choices[0].delta if getattr(chunk, "choices", None) else None
content = getattr(delta, "content", "") if delta else ""
except IndexError:
content = ""
if content:
if "<think>" in content:
is_active = False
content = content.split("<think>")[0]
if "</think>" in content:
is_active = True
content = content.split("</think>")[-1]
if is_active:
yield content
def response_with_functions(self, session_id, dialogue, functions=None, **kwargs):
try:
dialogue = self.normalize_dialogue(dialogue)
dialogue = self.normalize_dialogue(dialogue)
request_params = {
"model": self.model_name,
"messages": dialogue,
"stream": True,
"tools": functions,
}
request_params = {
"model": self.model_name,
"messages": dialogue,
"stream": True,
"tools": functions,
}
optional_params = {
"max_tokens": kwargs.get("max_tokens", self.max_tokens),
"temperature": kwargs.get("temperature", self.temperature),
"top_p": kwargs.get("top_p", self.top_p),
"frequency_penalty": kwargs.get("frequency_penalty", self.frequency_penalty),
}
optional_params = {
"max_tokens": kwargs.get("max_tokens", self.max_tokens),
"temperature": kwargs.get("temperature", self.temperature),
"top_p": kwargs.get("top_p", self.top_p),
"frequency_penalty": kwargs.get("frequency_penalty", self.frequency_penalty),
}
for key, value in optional_params.items():
if value is not None:
request_params[key] = value
for key, value in optional_params.items():
if value is not None:
request_params[key] = value
stream = self.client.chat.completions.create(**request_params)
stream = self.client.chat.completions.create(**request_params)
for chunk in stream:
if getattr(chunk, "choices", None):
delta = chunk.choices[0].delta
content = getattr(delta, "content", "")
tool_calls = getattr(delta, "tool_calls", None)
yield content, tool_calls
elif isinstance(getattr(chunk, "usage", None), CompletionUsage):
usage_info = getattr(chunk, "usage", None)
logger.bind(tag=TAG).info(
f"Token 消耗:输入 {getattr(usage_info, 'prompt_tokens', '未知')}"
f"输出 {getattr(usage_info, 'completion_tokens', '未知')}"
f"共计 {getattr(usage_info, 'total_tokens', '未知')}"
)
except Exception as e:
logger.bind(tag=TAG).error(f"Error in function call streaming: {e}")
yield f"【OpenAI服务响应异常: {e}", None
for chunk in stream:
if getattr(chunk, "choices", None):
delta = chunk.choices[0].delta
content = getattr(delta, "content", "")
tool_calls = getattr(delta, "tool_calls", None)
yield content, tool_calls
elif isinstance(getattr(chunk, "usage", None), CompletionUsage):
usage_info = getattr(chunk, "usage", None)
logger.bind(tag=TAG).info(
f"Token 消耗:输入 {getattr(usage_info, 'prompt_tokens', '未知')}"
f"输出 {getattr(usage_info, 'completion_tokens', '未知')}"
f"共计 {getattr(usage_info, 'total_tokens', '未知')}"
)
@@ -31,68 +31,55 @@ class LLMProvider(LLMProviderBase):
raise
def response(self, session_id, dialogue, **kwargs):
try:
logger.bind(tag=TAG).debug(
f"Sending request to Xinference with model: {self.model_name}, dialogue length: {len(dialogue)}"
)
responses = self.client.chat.completions.create(
model=self.model_name, messages=dialogue, stream=True
)
is_active = True
for chunk in responses:
try:
delta = (
chunk.choices[0].delta
if getattr(chunk, "choices", None)
else None
)
content = delta.content if hasattr(delta, "content") else ""
if content:
if "<think>" in content:
is_active = False
content = content.split("<think>")[0]
if "</think>" in content:
is_active = True
content = content.split("</think>")[-1]
if is_active:
yield content
except Exception as e:
logger.bind(tag=TAG).error(f"Error processing chunk: {e}")
except Exception as e:
logger.bind(tag=TAG).error(f"Error in Xinference response generation: {e}")
yield "【Xinference服务响应异常】"
logger.bind(tag=TAG).debug(
f"Sending request to Xinference with model: {self.model_name}, dialogue length: {len(dialogue)}"
)
responses = self.client.chat.completions.create(
model=self.model_name, messages=dialogue, stream=True
)
is_active = True
for chunk in responses:
try:
delta = (
chunk.choices[0].delta
if getattr(chunk, "choices", None)
else None
)
content = delta.content if hasattr(delta, "content") else ""
if content:
if "<think>" in content:
is_active = False
content = content.split("<think>")[0]
if "</think>" in content:
is_active = True
content = content.split("</think>")[-1]
if is_active:
yield content
except Exception as e:
logger.bind(tag=TAG).error(f"Error processing chunk: {e}")
def response_with_functions(self, session_id, dialogue, functions=None):
try:
logger.bind(tag=TAG).debug(
f"Sending function call request to Xinference with model: {self.model_name}, dialogue length: {len(dialogue)}"
)
if functions:
logger.bind(tag=TAG).debug(
f"Sending function call request to Xinference with model: {self.model_name}, dialogue length: {len(dialogue)}"
)
if functions:
logger.bind(tag=TAG).debug(
f"Function calls enabled with: {[f.get('function', {}).get('name') for f in functions]}"
)
stream = self.client.chat.completions.create(
model=self.model_name,
messages=dialogue,
stream=True,
tools=functions,
f"Function calls enabled with: {[f.get('function', {}).get('name') for f in functions]}"
)
for chunk in stream:
delta = chunk.choices[0].delta
content = delta.content
tool_calls = delta.tool_calls
stream = self.client.chat.completions.create(
model=self.model_name,
messages=dialogue,
stream=True,
tools=functions,
)
if content:
yield content, tool_calls
elif tool_calls:
yield None, tool_calls
for chunk in stream:
delta = chunk.choices[0].delta
content = delta.content
tool_calls = delta.tool_calls
except Exception as e:
logger.bind(tag=TAG).error(f"Error in Xinference function call: {e}")
yield {
"type": "content",
"content": f"【Xinference服务响应异常: {str(e)}",
}
if content:
yield content, tool_calls
elif tool_calls:
yield None, tool_calls
@@ -174,19 +174,19 @@ class MemoryProvider(MemoryProviderBase):
msgStr += f"当前时间:{time_str}"
if self.save_to_file:
result = self.llm.response_no_stream(
short_term_memory_prompt,
msgStr,
max_tokens=2000,
temperature=0.2,
)
json_str = extract_json_data(result)
try:
result = self.llm.response_no_stream(
short_term_memory_prompt,
msgStr,
max_tokens=2000,
temperature=0.2,
)
json_str = extract_json_data(result)
json.loads(json_str) # 检查json格式是否正确
self.short_memory = json_str
self.save_memory_to_file()
except Exception as e:
print("Error:", e)
logger.bind(tag=TAG).error(f"Error in saving memory: {e}")
else:
# 当save_to_file为False时,调用Java端的聊天记录总结接口
summary_id = session_id if session_id else self.role_id
@@ -141,6 +141,11 @@ async def handle_mcp_message(conn, mcp_client: MCPClient, payload: dict):
logger.bind(tag=TAG).debug(
f"客户端MCP服务器信息: name={name}, version={version}"
)
await asyncio.sleep(1)
logger.bind(tag=TAG).debug("初始化完成,开始请求MCP工具列表")
await send_mcp_tools_list_request(conn)
return
elif msg_id == 2: # mcpToolsListID
@@ -41,8 +41,6 @@ class TTSProvider(TTSProviderBase):
# 音频参数配置
self.format = config.get("format", "pcm")
sample_rate = config.get("sample_rate", "24000")
self.sample_rate = int(sample_rate) if sample_rate else 24000
volume = config.get("volume", "50")
self.volume = int(volume) if volume else 50
@@ -60,11 +58,6 @@ class TTSProvider(TTSProviderBase):
"X-DashScope-DataInspection": "enable",
}
# 创建Opus编码器
self.opus_encoder = opus_encoder_utils.OpusEncoderUtils(
sample_rate=self.sample_rate, channels=1, frame_size_ms=60
)
async def _ensure_connection(self):
"""确保WebSocket连接可用,支持60秒内连接复用"""
try:
@@ -245,7 +238,7 @@ class TTSProvider(TTSProviderBase):
"text_type": "PlainText",
"voice": self.voice,
"format": self.format,
"sample_rate": self.sample_rate,
"sample_rate": self.conn.sample_rate,
"volume": self.volume,
"rate": self.rate,
"pitch": self.pitch,
@@ -429,7 +422,7 @@ class TTSProvider(TTSProviderBase):
"text_type": "PlainText",
"voice": self.voice,
"format": self.format,
"sample_rate": self.sample_rate,
"sample_rate": self.conn.sample_rate,
"volume": self.volume,
"rate": self.rate,
"pitch": self.pitch,
@@ -95,8 +95,6 @@ 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
if config.get("private_voice"):
self.voice = config.get("private_voice")
@@ -172,7 +170,7 @@ class TTSProvider(TTSProviderBase):
"token": self.token,
"text": text,
"format": self.format,
"sample_rate": self.sample_rate,
"sample_rate": self.conn.sample_rate,
"voice": self.voice,
"volume": self.volume,
"speech_rate": self.speech_rate,
@@ -99,10 +99,6 @@ class TTSProvider(TTSProviderBase):
self.format = config.get("format", "pcm")
self.audio_file_type = config.get("format", "pcm")
# 采样率配置
sample_rate = config.get("sample_rate", "16000")
self.sample_rate = int(sample_rate) if sample_rate else 16000
# 音色配置 - CosyVoice大模型音色
if config.get("private_voice"):
self.voice = config.get("private_voice")
@@ -134,11 +130,6 @@ class TTSProvider(TTSProviderBase):
# 专属tts设置
self.task_id = uuid.uuid4().hex
# 创建Opus编码器
self.opus_encoder = opus_encoder_utils.OpusEncoderUtils(
sample_rate=16000, channels=1, frame_size_ms=60
)
# Token管理
if self.access_key_id and self.access_key_secret:
self._refresh_token()
@@ -344,7 +335,7 @@ class TTSProvider(TTSProviderBase):
"payload": {
"voice": self.voice,
"format": self.format,
"sample_rate": self.sample_rate,
"sample_rate": self.conn.sample_rate,
"volume": self.volume,
"speech_rate": self.speech_rate,
"pitch_rate": self.pitch_rate,
@@ -508,7 +499,7 @@ class TTSProvider(TTSProviderBase):
"payload": {
"voice": self.voice,
"format": self.format,
"sample_rate": self.sample_rate,
"sample_rate": self.conn.sample_rate,
"volume": self.volume,
"speech_rate": self.speech_rate,
"pitch_rate": self.pitch_rate,
+16 -5
View File
@@ -1,17 +1,18 @@
import os
import re
import time
import uuid
import queue
import asyncio
import threading
import traceback
from core.utils import p3
from datetime import datetime
from core.utils import textUtils
from typing import Callable, Any
from abc import ABC, abstractmethod
from config.logger import setup_logging
from core.utils import opus_encoder_utils
from core.utils.tts import MarkdownCleaner
from core.utils.output_counter import add_device_output
from core.handle.reportHandle import enqueue_tts_report
@@ -97,6 +98,8 @@ class TTSProviderBase(ABC):
file_type=self.audio_file_type,
is_opus=True,
callback=opus_handler,
sample_rate=self.conn.sample_rate,
opus_encoder=self.opus_encoder,
)
break
else:
@@ -138,7 +141,7 @@ class TTSProviderBase(ABC):
logger.bind(tag=TAG).error(
f"语音生成失败: {text},请检查网络或服务是否正常"
)
self.tts_audio_queue.put((SentenceType.FIRST, None, text))
self.tts_audio_queue.put((SentenceType.FIRST, None, text))
self._process_audio_file_stream(tmp_file, callback=opus_handler)
except Exception as e:
logger.bind(tag=TAG).error(f"Failed to generate TTS file: {e}")
@@ -158,7 +161,8 @@ class TTSProviderBase(ABC):
audio_bytes,
file_type=self.audio_file_type,
is_opus=True,
callback=lambda data: audio_datas.append(data)
callback=lambda data: audio_datas.append(data),
sample_rate=self.conn.sample_rate,
)
return audio_datas
else:
@@ -214,13 +218,13 @@ class TTSProviderBase(ABC):
self, audio_file_path, callback: Callable[[Any], Any] = None
):
"""音频文件转换为PCM编码"""
return audio_to_data_stream(audio_file_path, is_opus=False, callback=callback)
return audio_to_data_stream(audio_file_path, is_opus=False, callback=callback, sample_rate=self.conn.sample_rate, opus_encoder=None)
def audio_to_opus_data_stream(
self, audio_file_path, callback: Callable[[Any], Any] = None
):
"""音频文件转换为Opus编码"""
return audio_to_data_stream(audio_file_path, is_opus=True, callback=callback)
return audio_to_data_stream(audio_file_path, is_opus=True, callback=callback, sample_rate=self.conn.sample_rate, opus_encoder=self.opus_encoder)
def tts_one_sentence(
self,
@@ -252,6 +256,13 @@ class TTSProviderBase(ABC):
async def open_audio_channels(self, conn):
self.conn = conn
# 根据conn的sample_rate创建编码器,如果子类已经创建则不覆盖(IndexTTS接口返回为24kHZ-待重采样处理)
if not hasattr(self, 'opus_encoder') or self.opus_encoder is None:
self.opus_encoder = opus_encoder_utils.OpusEncoderUtils(
sample_rate=conn.sample_rate, channels=1, frame_size_ms=60
)
# tts 消化线程
self.tts_priority_thread = threading.Thread(
target=self.tts_text_priority_thread, daemon=True
@@ -154,16 +154,29 @@ class TTSProvider(TTSProviderBase):
self.voice = config.get("private_voice")
else:
self.voice = config.get("speaker")
speech_rate = config.get("speech_rate", "0")
loudness_rate = config.get("loudness_rate", "0")
pitch = config.get("pitch", "0")
self.speech_rate = int(speech_rate) if speech_rate else 0
self.loudness_rate = int(loudness_rate) if loudness_rate else 0
self.pitch = int(pitch) if pitch else 0
# 多情感音色参数
self.emotion = config.get("emotion", "neutral")
emotion_scale = config.get("emotion_scale", "4")
self.emotion_scale = int(emotion_scale) if emotion_scale else 4
# 默认 audio_params 配置
default_audio_params = {
"speech_rate": 0,
"loudness_rate": 0
}
# 默认 additions 配置
default_additions = {
"aigc_metadata": {},
"cache_config": {},
"post_process": {
"pitch": 0
}
}
# 默认 mix_speaker 配置
default_mix_speaker = {}
# 合并用户配置
self.audio_params = {**default_audio_params, **config.get("audio_params", {})}
self.additions = {**default_additions, **config.get("additions", {})}
self.mix_speaker = {**default_mix_speaker, **config.get("mix_speaker", {})}
self.ws_url = config.get("ws_url")
self.authorization = config.get("authorization")
@@ -171,9 +184,7 @@ class TTSProvider(TTSProviderBase):
enable_ws_reuse_value = config.get("enable_ws_reuse", True)
self.enable_ws_reuse = False if str(enable_ws_reuse_value).lower() == 'false' else True
self.tts_text = ""
self.opus_encoder = opus_encoder_utils.OpusEncoderUtils(
sample_rate=16000, channels=1, frame_size_ms=60
)
model_key_msg = check_model_key("TTS", self.access_token)
if model_key_msg:
logger.bind(tag=TAG).error(model_key_msg)
@@ -181,6 +192,8 @@ class TTSProvider(TTSProviderBase):
async def open_audio_channels(self, conn):
try:
await super().open_audio_channels(conn)
# 更新 audio_params 中的采样率为实际的 conn.sample_rate
self.audio_params["sample_rate"] = conn.sample_rate
except Exception as e:
logger.bind(tag=TAG).error(f"Failed to open audio channels: {str(e)}")
self.ws = None
@@ -646,20 +659,18 @@ class TTSProvider(TTSProviderBase):
text="",
speaker="",
audio_format="pcm",
audio_sample_rate=16000,
):
audio_params = {
"format": audio_format,
"sample_rate": audio_sample_rate,
"speech_rate": self.speech_rate,
"loudness_rate": self.loudness_rate
# 构建 req_params
req_params = {
"text": text,
"speaker": speaker,
"audio_params": {**self.audio_params, "format": audio_format},
"additions": json.dumps(self.additions)
}
# 如果是多情感音色,添加情感参数
if '_emo_' in self.voice:
if self.emotion:
audio_params["emotion"] = self.emotion
audio_params["emotion_scale"] = self.emotion_scale
# 如果有 mix_speaker 配置,添加到 req_params
if self.mix_speaker:
req_params["mix_speaker"] = self.mix_speaker
return str.encode(
json.dumps(
@@ -667,17 +678,7 @@ class TTSProvider(TTSProviderBase):
"user": {"uid": uid},
"event": event,
"namespace": "BidirectionalTTS",
"req_params": {
"text": text,
"speaker": speaker,
"audio_params": audio_params,
"additions": json.dumps({
"post_process": {
"pitch": self.pitch
}
})
},
"req_params": req_params
}
)
)
@@ -174,6 +174,20 @@ class TTSProvider(TTSProviderBase):
logger.bind(tag=TAG).error(f"TTS请求异常: {e}")
self.tts_audio_queue.put((SentenceType.LAST, [], None))
def audio_to_pcm_data_stream(
self, audio_file_path, callback=None
):
"""音频文件转换为PCM编码,使用24kHz采样率"""
from core.utils.util import audio_to_data_stream
return audio_to_data_stream(audio_file_path, is_opus=False, callback=callback, sample_rate=24000, opus_encoder=None)
def audio_to_opus_data_stream(
self, audio_file_path, callback=None
):
"""音频文件转换为Opus编码,使用24kHz采样率和自己的编码器"""
from core.utils.util import audio_to_data_stream
return audio_to_data_stream(audio_file_path, is_opus=True, callback=callback, sample_rate=24000, opus_encoder=self.opus_encoder)
async def close(self):
"""资源清理"""
await super().close()
@@ -25,11 +25,6 @@ class TTSProvider(TTSProviderBase):
self.audio_format = "pcm"
self.before_stop_play_files = []
# 创建Opus编码器
self.opus_encoder = opus_encoder_utils.OpusEncoderUtils(
sample_rate=16000, channels=1, frame_size_ms=60
)
# PCM缓冲区
self.pcm_buffer = bytearray()
@@ -127,7 +122,7 @@ class TTSProvider(TTSProviderBase):
"spk_id": self.voice,
"frame_durition": 60,
"stream": "true",
"target_sr": 16000,
"target_sr": self.conn.sample_rate,
"audio_format": "pcm",
"instruct_text": "请生成一段自然流畅的语音",
}
@@ -136,7 +131,7 @@ class TTSProvider(TTSProviderBase):
"Content-Type": "application/json",
}
# 一帧 PCM 所需字节数:60 ms &times; 16 kHz &times; 1 ch &times; 2 B = 1 920
# 一帧 PCM 所需字节数:60 ms × sample_rate × 1 ch × 2 B
frame_bytes = int(
self.opus_encoder.sample_rate
* self.opus_encoder.channels # 1
@@ -213,7 +208,7 @@ class TTSProvider(TTSProviderBase):
"spk_id": self.voice,
"frame_duration": 60,
"stream": False,
"target_sr": 16000,
"target_sr": self.conn.sample_rate,
"audio_format": self.audio_format,
"instruct_text": "请生成一段自然流畅的语音",
}
@@ -64,13 +64,17 @@ class TTSProvider(TTSProviderBase):
}
self.audio_file_type = defult_audio_setting.get("format", "pcm")
self.opus_encoder = opus_encoder_utils.OpusEncoderUtils(
sample_rate=24000, channels=1, frame_size_ms=60
)
# PCM缓冲区
self.pcm_buffer = bytearray()
async def open_audio_channels(self, conn):
"""初始化音频通道,并根据conn.sample_rate更新配置"""
# 调用父类方法
await super().open_audio_channels(conn)
# 更新audio_setting中的采样率为实际的conn.sample_rate
self.audio_setting["sample_rate"] = conn.sample_rate
def tts_text_priority_thread(self):
"""流式文本处理线程"""
while not self.conn.stop_event.is_set():
@@ -212,6 +216,18 @@ class TTSProvider(TTSProviderBase):
try:
data = json.loads(json_str)
# 检查业务层错误
base_resp = data.get("base_resp", {})
status_code = base_resp.get("status_code", 0)
if status_code != 0:
status_msg = base_resp.get("status_msg", "未知错误")
logger.bind(tag=TAG).error(
f"TTS请求失败, 错误码:{status_code}, 错误消息:{status_msg}"
)
self.tts_audio_queue.put((SentenceType.LAST, [], None))
return
status = data.get("data", {}).get("status", 1)
audio_hex = data.get("data", {}).get("audio")
@@ -25,10 +25,7 @@ class TTSProvider(TTSProviderBase):
self.spk_id = int(config.get("private_voice"))
else:
self.spk_id = int(config.get("spk_id", "0"))
sample_rate = config.get("sample_rate", 24000)
self.sample_rate = float(sample_rate) if sample_rate else 24000
speed = config.get("speed", 1.0)
self.speed = float(speed) if speed else 1.0
@@ -13,7 +13,6 @@ class TTSProvider(TTSProviderBase):
self.voice = config.get("voice")
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")
@@ -91,9 +91,6 @@ class TTSProvider(TTSProviderBase):
# 音频编码配置
self.format = config.get("format", "raw")
sample_rate = config.get("sample_rate", "24000")
self.sample_rate = int(sample_rate) if sample_rate else 24000
# 口语化配置
self.oral_level = config.get("oral_level", "mid")
@@ -113,11 +110,6 @@ class TTSProvider(TTSProviderBase):
# 序列号管理
self.text_seq = 0
# 创建Opus编码器
self.opus_encoder = opus_encoder_utils.OpusEncoderUtils(
sample_rate=self.sample_rate, channels=1, frame_size_ms=60
)
# 验证必需参数
if not all([self.app_id, self.api_key, self.api_secret]):
raise ValueError("讯飞TTS需要配置app_id、api_key和api_secret")
@@ -507,7 +499,7 @@ class TTSProvider(TTSProviderBase):
"rhy": 0,
"audio": {
"encoding": self.format,
"sample_rate": self.sample_rate,
"sample_rate": self.conn.sample_rate,
"channels": 1,
"bit_depth": 16,
"frame_size": 0
+44 -16
View File
@@ -227,7 +227,7 @@ def extract_json_from_string(input_string):
def audio_to_data_stream(
audio_file_path, is_opus=True, callback: Callable[[Any], Any] = None
audio_file_path, is_opus=True, callback: Callable[[Any], Any] = None, sample_rate=16000, opus_encoder=None
) -> None:
# 获取文件后缀名
file_type = os.path.splitext(audio_file_path)[1]
@@ -238,12 +238,12 @@ def audio_to_data_stream(
audio_file_path, format=file_type, parameters=["-nostdin"]
)
# 转换为单声道/16kHz采样率/16位小端编码(确保与编码器匹配)
audio = audio.set_channels(1).set_frame_rate(16000).set_sample_width(2)
# 转换为单声道/指定采样率/16位小端编码(确保与编码器匹配)
audio = audio.set_channels(1).set_frame_rate(sample_rate).set_sample_width(2)
# 获取原始PCM数据(16位小端)
raw_data = audio.raw_data
pcm_to_data_stream(raw_data, is_opus, callback)
pcm_to_data_stream(raw_data, is_opus, callback, sample_rate, opus_encoder)
async def audio_to_data(
@@ -325,7 +325,7 @@ async def audio_to_data(
def audio_bytes_to_data_stream(
audio_bytes, file_type, is_opus, callback: Callable[[Any], Any]
audio_bytes, file_type, is_opus, callback: Callable[[Any], Any], sample_rate=16000, opus_encoder=None
) -> None:
"""
直接用音频二进制数据转为opus/pcm数据,支持wav、mp3、p3
@@ -338,18 +338,30 @@ def audio_bytes_to_data_stream(
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)
audio = audio.set_channels(1).set_frame_rate(sample_rate).set_sample_width(2)
raw_data = audio.raw_data
pcm_to_data_stream(raw_data, is_opus, callback)
pcm_to_data_stream(raw_data, is_opus, callback, sample_rate, opus_encoder)
def pcm_to_data_stream(raw_data, is_opus=True, callback: Callable[[Any], Any] = None):
# 初始化Opus编码器
encoder = opuslib_next.Encoder(16000, 1, opuslib_next.APPLICATION_AUDIO)
def pcm_to_data_stream(raw_data, is_opus=True, callback: Callable[[Any], Any] = None, sample_rate=16000, opus_encoder=None):
"""
将PCM数据流式编码为Opus或直接输出PCM
Args:
raw_data: PCM原始数据
is_opus: 是否编码为Opus
callback: 回调函数
sample_rate: 采样率
opus_encoder: OpusEncoderUtils对象(推荐提供以保持编码器状态连续)
"""
using_temp_encoder = False
if is_opus and opus_encoder is None:
encoder = opuslib_next.Encoder(sample_rate, 1, opuslib_next.APPLICATION_AUDIO)
using_temp_encoder = True
# 编码参数
frame_duration = 60 # 60ms per frame
frame_size = int(16000 * frame_duration / 1000) # 960 samples/frame
frame_size = int(sample_rate * frame_duration / 1000) # samples/frame
# 按帧处理所有音频数据(包括最后一帧可能补零)
for i in range(0, len(raw_data), frame_size * 2): # 16bit=2bytes/sample
@@ -361,12 +373,17 @@ def pcm_to_data_stream(raw_data, is_opus=True, callback: Callable[[Any], Any] =
chunk += b"\x00" * (frame_size * 2 - len(chunk))
if is_opus:
# 转换为numpy数组处理
np_frame = np.frombuffer(chunk, dtype=np.int16)
# 编码Opus数据
frame_data = encoder.encode(np_frame.tobytes(), frame_size)
callback(frame_data)
if using_temp_encoder:
# 使用临时编码器(仅用于独立音频场景)
np_frame = np.frombuffer(chunk, dtype=np.int16)
frame_data = encoder.encode(np_frame.tobytes(), frame_size)
callback(frame_data)
else:
# 使用外部编码器(TTS流式场景,保持状态连续)
is_last = (i + frame_size * 2 >= len(raw_data))
opus_encoder.encode_pcm_to_opus_stream(chunk, end_of_stream=is_last, callback=callback)
else:
# PCM模式,直接输出
frame_data = chunk if isinstance(chunk, bytes) else bytes(chunk)
callback(frame_data)
@@ -573,3 +590,14 @@ def validate_mcp_endpoint(mcp_endpoint: str) -> bool:
return False
return True
def get_system_error_response(config: dict) -> str:
"""获取系统错误时的回复
Args:
config: 配置字典
Returns:
str: 系统错误时的回复
"""
return config.get("system_error_response", "主人,小智现在有点忙,我们稍后再试吧。")
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

@@ -0,0 +1,72 @@
============================================================
示例模型
桃濑日和 - PRO
============================================================
该示例时基于Cubism3.0制作的标准模型素材。
可用于学习变形器的构造以及参数的使用方法。
模型的肩部应用了新功能【胶水】。
------------------------------
素材使用许可
------------------------------
 普通用户以及小规模企业在同意授权协议的情况下可用于商业用途。
 中/大规模的企业只能用于非公开的内部试用。
 在使用该素材时,请确认以下的【无偿提供素材使用授权协议】中的“授权类型”、“Live2D原创角色”等的相关内容,
 并必须接受【Live2D Cubism 示例模型的使用授权要求】中的利用条件。
 有关许可证的更多信息,请参阅以下页面。
 https://www.live2d.com/zh-CHS/download/sample-data/
------------------------------
创作者
------------------------------
插画:Kani Biimu
模型:Live2D
------------------------------
素材内容
------------------------------
模型文件(cmo3) ※包含物理模拟的设定
动画文件(can3)
嵌入文件列表(runtime文件夹)
・模型数据(moc3)
・动作数据(motion3.json)
・模型设定文件(model3.json)
・物理模拟设定文件(physics3.json)
・姿势设定文件(pose3.json)
・辅助显示的文件(cdi3.json)
------------------------------
更新记录
------------------------------
【cmo3】
 hiyori_pro_t11
 2023年03月08日 修改了部分模型关键点
hiyori_pro_t10
2021年06月10日 公开
【can3】
 hiyori_pro_t04
 2023年03月08日 修改了部分动画关键帧
hiyori_pro_t03
2021年06月10日 公开
 
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

@@ -0,0 +1,533 @@
{
"Version": 3,
"Parameters": [
{
"Id": "ParamAngleX",
"GroupId": "ParamGroupFace",
"Name": "角度 X"
},
{
"Id": "ParamAngleY",
"GroupId": "ParamGroupFace",
"Name": "角度 Y"
},
{
"Id": "ParamAngleZ",
"GroupId": "ParamGroupFace",
"Name": "角度 Z"
},
{
"Id": "ParamCheek",
"GroupId": "ParamGroupFace",
"Name": "脸颊泛红"
},
{
"Id": "ParamEyeLOpen",
"GroupId": "ParamGroupEyes",
"Name": "左眼 开闭"
},
{
"Id": "ParamEyeLSmile",
"GroupId": "ParamGroupEyes",
"Name": "左眼 微笑"
},
{
"Id": "ParamEyeROpen",
"GroupId": "ParamGroupEyes",
"Name": "右眼 开闭"
},
{
"Id": "ParamEyeRSmile",
"GroupId": "ParamGroupEyes",
"Name": "右眼 微笑"
},
{
"Id": "ParamEyeBallX",
"GroupId": "ParamGroupEyeballs",
"Name": "眼珠 X"
},
{
"Id": "ParamEyeBallY",
"GroupId": "ParamGroupEyeballs",
"Name": "眼珠 Y"
},
{
"Id": "ParamBrowLY",
"GroupId": "ParamGroupBrows",
"Name": "左眉 上下"
},
{
"Id": "ParamBrowRY",
"GroupId": "ParamGroupBrows",
"Name": "右眉 上下"
},
{
"Id": "ParamBrowLX",
"GroupId": "ParamGroupBrows",
"Name": "左眉 左右"
},
{
"Id": "ParamBrowRX",
"GroupId": "ParamGroupBrows",
"Name": "右眉 左右"
},
{
"Id": "ParamBrowLAngle",
"GroupId": "ParamGroupBrows",
"Name": "左眉 角度"
},
{
"Id": "ParamBrowRAngle",
"GroupId": "ParamGroupBrows",
"Name": "右眉 角度"
},
{
"Id": "ParamBrowLForm",
"GroupId": "ParamGroupBrows",
"Name": "左眉 变形"
},
{
"Id": "ParamBrowRForm",
"GroupId": "ParamGroupBrows",
"Name": "右眉 变形"
},
{
"Id": "ParamMouthForm",
"GroupId": "ParamGroupMouth",
"Name": "嘴 变形"
},
{
"Id": "ParamMouthOpenY",
"GroupId": "ParamGroupMouth",
"Name": "嘴 开闭"
},
{
"Id": "ParamBodyAngleX",
"GroupId": "ParamGroupBody",
"Name": "身体的旋转 X"
},
{
"Id": "ParamBodyAngleY",
"GroupId": "ParamGroupBody",
"Name": "身体的旋转 Y"
},
{
"Id": "ParamBodyAngleZ",
"GroupId": "ParamGroupBody",
"Name": "身体的旋转 Z"
},
{
"Id": "ParamBreath",
"GroupId": "ParamGroupBody",
"Name": "呼吸"
},
{
"Id": "ParamShoulder",
"GroupId": "ParamGroupBody",
"Name": "肩"
},
{
"Id": "ParamLeg",
"GroupId": "ParamGroupBody",
"Name": "腿"
},
{
"Id": "ParamArmLA",
"GroupId": "ParamGroupArms",
"Name": "左臂 A"
},
{
"Id": "ParamArmRA",
"GroupId": "ParamGroupArms",
"Name": "右臂 A"
},
{
"Id": "ParamArmLB",
"GroupId": "ParamGroupArms",
"Name": "左臂 B"
},
{
"Id": "ParamArmRB",
"GroupId": "ParamGroupArms",
"Name": "右臂 B"
},
{
"Id": "ParamHandLB",
"GroupId": "ParamGroupArms",
"Name": "左手B 旋转"
},
{
"Id": "ParamHandRB",
"GroupId": "ParamGroupArms",
"Name": "右手B 旋转"
},
{
"Id": "ParamHandL",
"GroupId": "ParamGroupArms",
"Name": "左手"
},
{
"Id": "ParamHandR",
"GroupId": "ParamGroupArms",
"Name": "右手"
},
{
"Id": "ParamBustY",
"GroupId": "ParamGroupSway",
"Name": "胸部 摇动"
},
{
"Id": "ParamHairAhoge",
"GroupId": "ParamGroupSway",
"Name": "头发摇动 呆毛"
},
{
"Id": "ParamHairFront",
"GroupId": "ParamGroupSway",
"Name": "头发摇动 前"
},
{
"Id": "ParamHairBack",
"GroupId": "ParamGroupSway",
"Name": "头发摇动 后"
},
{
"Id": "ParamSideupRibbon",
"GroupId": "ParamGroupSway",
"Name": "发饰的摇动"
},
{
"Id": "ParamRibbon",
"GroupId": "ParamGroupSway",
"Name": "蝴蝶结的摇动"
},
{
"Id": "ParamSkirt",
"GroupId": "ParamGroupSway",
"Name": "短裙的摇动"
},
{
"Id": "ParamSkirt2",
"GroupId": "ParamGroupSway",
"Name": "短裙的上卷"
},
{
"Id": "Param_Angle_Rotation_1_ArtMesh62",
"GroupId": "ParamGroup2",
"Name": "[0]辫子左"
},
{
"Id": "Param_Angle_Rotation_2_ArtMesh62",
"GroupId": "ParamGroup2",
"Name": "[1]辫子左"
},
{
"Id": "Param_Angle_Rotation_3_ArtMesh62",
"GroupId": "ParamGroup2",
"Name": "[2]辫子左"
},
{
"Id": "Param_Angle_Rotation_4_ArtMesh62",
"GroupId": "ParamGroup2",
"Name": "[3]辫子左"
},
{
"Id": "Param_Angle_Rotation_5_ArtMesh62",
"GroupId": "ParamGroup2",
"Name": "[4]辫子左"
},
{
"Id": "Param_Angle_Rotation_6_ArtMesh62",
"GroupId": "ParamGroup2",
"Name": "[5]辫子左"
},
{
"Id": "Param_Angle_Rotation_7_ArtMesh62",
"GroupId": "ParamGroup2",
"Name": "[6]辫子左"
},
{
"Id": "Param_Angle_Rotation_1_ArtMesh61",
"GroupId": "ParamGroup",
"Name": "[0]辫子右"
},
{
"Id": "Param_Angle_Rotation_2_ArtMesh61",
"GroupId": "ParamGroup",
"Name": "[1]辫子右"
},
{
"Id": "Param_Angle_Rotation_3_ArtMesh61",
"GroupId": "ParamGroup",
"Name": "[2]辫子右"
},
{
"Id": "Param_Angle_Rotation_4_ArtMesh61",
"GroupId": "ParamGroup",
"Name": "[3]辫子右"
},
{
"Id": "Param_Angle_Rotation_5_ArtMesh61",
"GroupId": "ParamGroup",
"Name": "[4]辫子右"
},
{
"Id": "Param_Angle_Rotation_6_ArtMesh61",
"GroupId": "ParamGroup",
"Name": "[5]辫子右"
},
{
"Id": "Param_Angle_Rotation_7_ArtMesh61",
"GroupId": "ParamGroup",
"Name": "[6]辫子右"
},
{
"Id": "Param_Angle_Rotation_1_ArtMesh55",
"GroupId": "ParamGroup4",
"Name": "[0]侧发左"
},
{
"Id": "Param_Angle_Rotation_2_ArtMesh55",
"GroupId": "ParamGroup4",
"Name": "[1]侧发左"
},
{
"Id": "Param_Angle_Rotation_3_ArtMesh55",
"GroupId": "ParamGroup4",
"Name": "[2]侧发左"
},
{
"Id": "Param_Angle_Rotation_4_ArtMesh55",
"GroupId": "ParamGroup4",
"Name": "[3]侧发左"
},
{
"Id": "Param_Angle_Rotation_5_ArtMesh55",
"GroupId": "ParamGroup4",
"Name": "[4]侧发左"
},
{
"Id": "Param_Angle_Rotation_6_ArtMesh55",
"GroupId": "ParamGroup4",
"Name": "[5]侧发左"
},
{
"Id": "Param_Angle_Rotation_7_ArtMesh55",
"GroupId": "ParamGroup4",
"Name": "[6]侧发左"
},
{
"Id": "Param_Angle_Rotation_1_ArtMesh54",
"GroupId": "ParamGroup3",
"Name": "[0]侧发右"
},
{
"Id": "Param_Angle_Rotation_2_ArtMesh54",
"GroupId": "ParamGroup3",
"Name": "[1]侧发右"
},
{
"Id": "Param_Angle_Rotation_3_ArtMesh54",
"GroupId": "ParamGroup3",
"Name": "[2]侧发右"
},
{
"Id": "Param_Angle_Rotation_4_ArtMesh54",
"GroupId": "ParamGroup3",
"Name": "[3]侧发右"
},
{
"Id": "Param_Angle_Rotation_5_ArtMesh54",
"GroupId": "ParamGroup3",
"Name": "[4]侧发右"
},
{
"Id": "Param_Angle_Rotation_6_ArtMesh54",
"GroupId": "ParamGroup3",
"Name": "[5]侧发右"
},
{
"Id": "Param_Angle_Rotation_7_ArtMesh54",
"GroupId": "ParamGroup3",
"Name": "[6]侧发右"
}
],
"ParameterGroups": [
{
"Id": "ParamGroupFace",
"GroupId": "",
"Name": "脸"
},
{
"Id": "ParamGroupEyes",
"GroupId": "",
"Name": "眼睛"
},
{
"Id": "ParamGroupEyeballs",
"GroupId": "",
"Name": "眼珠"
},
{
"Id": "ParamGroupBrows",
"GroupId": "",
"Name": "眉毛"
},
{
"Id": "ParamGroupMouth",
"GroupId": "",
"Name": "嘴"
},
{
"Id": "ParamGroupBody",
"GroupId": "",
"Name": "身体"
},
{
"Id": "ParamGroupArms",
"GroupId": "",
"Name": "手臂"
},
{
"Id": "ParamGroupSway",
"GroupId": "",
"Name": "摇动"
},
{
"Id": "ParamGroup2",
"GroupId": "",
"Name": "摇动 辫子左"
},
{
"Id": "ParamGroup",
"GroupId": "",
"Name": "摇动 辫子右"
},
{
"Id": "ParamGroup4",
"GroupId": "",
"Name": "摇动 侧发左"
},
{
"Id": "ParamGroup3",
"GroupId": "",
"Name": "摇动 侧发右"
}
],
"Parts": [
{
"Id": "PartCore",
"Name": "Core"
},
{
"Id": "PartCheek",
"Name": "脸颊"
},
{
"Id": "PartBrow",
"Name": "眉毛"
},
{
"Id": "PartEye",
"Name": "眼睛"
},
{
"Id": "PartNose",
"Name": "鼻子"
},
{
"Id": "PartMouth",
"Name": "嘴"
},
{
"Id": "PartFace",
"Name": "脸"
},
{
"Id": "PartEar",
"Name": "耳朵"
},
{
"Id": "PartHairSide",
"Name": "侧发"
},
{
"Id": "PartHairFront",
"Name": "前发"
},
{
"Id": "PartHairBack",
"Name": "后发"
},
{
"Id": "PartNeck",
"Name": "脖子"
},
{
"Id": "PartArmA",
"Name": "手臂A"
},
{
"Id": "PartArmB",
"Name": "手臂B"
},
{
"Id": "PartBody",
"Name": "身体"
},
{
"Id": "PartBackground",
"Name": "背景"
},
{
"Id": "PartSketch",
"Name": "[参考图]"
},
{
"Id": "PartEyeBall",
"Name": "眼珠"
},
{
"Id": "ArtMesh55_Skinning",
"Name": "侧发右"
},
{
"Id": "Part4",
"Name": "侧发左(旋转)"
},
{
"Id": "ArtMesh54_Skinning",
"Name": "侧发右(蒙皮)"
},
{
"Id": "Part3",
"Name": "侧发右(旋转)"
},
{
"Id": "ArtMesh61_Skinning",
"Name": "辫子右(蒙皮)"
},
{
"Id": "Part",
"Name": "辫子右(旋转)"
},
{
"Id": "ArtMesh62_Skinning",
"Name": "辫子左(蒙皮)"
},
{
"Id": "Part2",
"Name": "辫子左(旋转)"
}
],
"CombinedParameters": [
[
"ParamAngleX",
"ParamAngleY"
],
[
"ParamMouthForm",
"ParamMouthOpenY"
]
]
}
@@ -0,0 +1,82 @@
{
"Version": 3,
"FileReferences": {
"Moc": "hiyori_pro_t11.moc3",
"Textures": [
"hiyori_pro_t11.2048/texture_00.png",
"hiyori_pro_t11.2048/texture_01.png"
],
"Physics": "hiyori_pro_t11.physics3.json",
"Pose": "hiyori_pro_t11.pose3.json",
"DisplayInfo": "hiyori_pro_t11.cdi3.json",
"Motions": {
"Idle": [
{
"File": "motion/hiyori_m01.motion3.json"
},
{
"File": "motion/hiyori_m02.motion3.json"
},
{
"File": "motion/hiyori_m05.motion3.json"
}
],
"Flick": [
{
"File": "motion/hiyori_m03.motion3.json"
}
],
"FlickDown": [
{
"File": "motion/hiyori_m04.motion3.json"
}
],
"FlickUp": [
{
"File": "motion/hiyori_m06.motion3.json"
}
],
"Tap": [
{
"File": "motion/hiyori_m07.motion3.json"
},
{
"File": "motion/hiyori_m08.motion3.json"
}
],
"Tap@Body": [
{
"File": "motion/hiyori_m09.motion3.json"
}
],
"Flick@Body": [
{
"File": "motion/hiyori_m10.motion3.json"
}
]
}
},
"Groups": [
{
"Target": "Parameter",
"Name": "LipSync",
"Ids": [
"ParamMouthOpenY"
]
},
{
"Target": "Parameter",
"Name": "EyeBlink",
"Ids": [
"ParamEyeLOpen",
"ParamEyeROpen"
]
}
],
"HitAreas": [
{
"Id": "HitArea",
"Name": "Body"
}
]
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,15 @@
{
"Type": "Live2D Pose",
"Groups": [
[
{
"Id": "PartArmA",
"Link": []
},
{
"Id": "PartArmB",
"Link": []
}
]
]
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,860 @@
{
"Version": 3,
"Meta": {
"Duration": 4.43,
"Fps": 30.0,
"Loop": true,
"AreBeziersRestricted": false,
"CurveCount": 29,
"TotalSegmentCount": 106,
"TotalPointCount": 287,
"UserDataCount": 0,
"TotalUserDataSize": 0
},
"Curves": [
{
"Target": "Parameter",
"Id": "ParamAngleX",
"Segments": [
0,
1,
1,
0.211,
1,
0.422,
0,
0.633,
0,
1,
0.911,
0,
1.189,
5,
1.467,
5,
1,
1.689,
5,
1.911,
-16,
2.133,
-16,
1,
2.356,
-16,
2.578,
13.871,
2.8,
13.871,
1,
2.956,
13.871,
3.111,
0,
3.267,
0,
0,
4.433,
0
]
},
{
"Target": "Parameter",
"Id": "ParamAngleY",
"Segments": [
0,
0,
1,
0.211,
0,
0.422,
0,
0.633,
0,
1,
0.911,
0,
1.189,
-25,
1.467,
-25,
1,
1.689,
-25,
1.911,
-15.225,
2.133,
-11,
1,
2.356,
-6.775,
2.578,
-5.127,
2.8,
-2.5,
1,
2.956,
-0.661,
3.111,
0,
3.267,
0,
0,
4.433,
0
]
},
{
"Target": "Parameter",
"Id": "ParamAngleZ",
"Segments": [
0,
0,
1,
0.222,
0,
0.444,
0,
0.667,
0,
1,
0.756,
0,
0.844,
-4,
0.933,
-4,
1,
1.122,
-4,
1.311,
18,
1.5,
18,
1,
1.722,
18,
1.944,
-14,
2.167,
-14,
1,
2.567,
-14,
2.967,
-14,
3.367,
-14,
1,
3.511,
-14,
3.656,
-12,
3.8,
-12,
0,
4.433,
-12
]
},
{
"Target": "Parameter",
"Id": "ParamCheek",
"Segments": [
0,
1,
0,
4.433,
1
]
},
{
"Target": "Parameter",
"Id": "ParamEyeLOpen",
"Segments": [
0,
1.2,
1,
0.389,
1.2,
0.778,
1.148,
1.167,
1,
1,
1.211,
0.983,
1.256,
0,
1.3,
0,
1,
1.322,
0,
1.344,
0,
1.367,
0,
1,
1.422,
0,
1.478,
1,
1.533,
1,
1,
1.944,
1,
2.356,
1,
2.767,
1,
1,
2.811,
1,
2.856,
0,
2.9,
0,
1,
2.922,
0,
2.944,
0,
2.967,
0,
1,
3.022,
0,
3.078,
1,
3.133,
1,
0,
4.433,
1
]
},
{
"Target": "Parameter",
"Id": "ParamEyeLSmile",
"Segments": [
0,
0,
0,
4.433,
0
]
},
{
"Target": "Parameter",
"Id": "ParamEyeROpen",
"Segments": [
0,
1.2,
1,
0.389,
1.2,
0.778,
1.148,
1.167,
1,
1,
1.211,
0.983,
1.256,
0,
1.3,
0,
1,
1.322,
0,
1.344,
0,
1.367,
0,
1,
1.422,
0,
1.478,
1,
1.533,
1,
1,
1.944,
1,
2.356,
1,
2.767,
1,
1,
2.811,
1,
2.856,
0,
2.9,
0,
1,
2.922,
0,
2.944,
0,
2.967,
0,
1,
3.022,
0,
3.078,
1,
3.133,
1,
0,
4.433,
1
]
},
{
"Target": "Parameter",
"Id": "ParamEyeRSmile",
"Segments": [
0,
0,
0,
4.433,
0
]
},
{
"Target": "Parameter",
"Id": "ParamEyeBallX",
"Segments": [
0,
0,
1,
0.211,
0,
0.422,
0,
0.633,
0,
1,
0.911,
0,
1.189,
-0.44,
1.467,
-0.44,
1,
1.689,
-0.44,
1.911,
0.79,
2.133,
0.79,
1,
2.511,
0.79,
2.889,
0,
3.267,
0,
0,
4.433,
0
]
},
{
"Target": "Parameter",
"Id": "ParamEyeBallY",
"Segments": [
0,
0,
1,
0.211,
0,
0.422,
0,
0.633,
0,
1,
0.911,
0,
1.189,
-1,
1.467,
-1,
1,
1.689,
-1,
1.911,
-1,
2.133,
-1,
1,
2.511,
-1,
2.889,
0,
3.267,
0,
0,
4.433,
0
]
},
{
"Target": "Parameter",
"Id": "ParamBrowLY",
"Segments": [
0,
1,
1,
0.544,
1,
1.089,
1,
1.633,
1,
1,
1.856,
1,
2.078,
0,
2.3,
0,
1,
2.5,
0,
2.7,
1,
2.9,
1,
0,
4.433,
1
]
},
{
"Target": "Parameter",
"Id": "ParamBrowRY",
"Segments": [
0,
1,
1,
0.544,
1,
1.089,
1,
1.633,
1,
1,
1.856,
1,
2.078,
0,
2.3,
0,
1,
2.5,
0,
2.7,
1,
2.9,
1,
0,
4.433,
1
]
},
{
"Target": "Parameter",
"Id": "ParamBrowLX",
"Segments": [
0,
0,
0,
4.433,
0
]
},
{
"Target": "Parameter",
"Id": "ParamBrowRX",
"Segments": [
0,
0,
0,
4.433,
0
]
},
{
"Target": "Parameter",
"Id": "ParamBrowLAngle",
"Segments": [
0,
0,
0,
4.433,
0
]
},
{
"Target": "Parameter",
"Id": "ParamBrowRAngle",
"Segments": [
0,
0,
0,
4.433,
0
]
},
{
"Target": "Parameter",
"Id": "ParamBrowLForm",
"Segments": [
0,
-1,
0,
4.433,
-1
]
},
{
"Target": "Parameter",
"Id": "ParamBrowRForm",
"Segments": [
0,
-1,
0,
4.433,
-1
]
},
{
"Target": "Parameter",
"Id": "ParamBodyAngleX",
"Segments": [
0,
0,
1,
0.244,
0,
0.489,
0,
0.733,
0,
1,
0.933,
0,
1.133,
-7,
1.333,
-7,
1,
1.644,
-7,
1.956,
0,
2.267,
0,
0,
4.433,
0
]
},
{
"Target": "Parameter",
"Id": "ParamBodyAngleY",
"Segments": [
0,
0,
1,
0.244,
0,
0.489,
0,
0.733,
0,
0,
4.433,
0
]
},
{
"Target": "Parameter",
"Id": "ParamBodyAngleZ",
"Segments": [
0,
2,
1,
0.233,
2,
0.467,
0,
0.7,
0,
1,
0.733,
0,
0.767,
0,
0.8,
0,
1,
1,
0,
1.2,
-4,
1.4,
-4,
1,
1.711,
-4,
2.022,
5,
2.333,
5,
1,
2.567,
5,
2.8,
3.64,
3.033,
0,
1,
3.133,
-1.56,
3.233,
-3,
3.333,
-3,
1,
3.467,
-3,
3.6,
-2,
3.733,
-2,
0,
4.433,
-2
]
},
{
"Target": "Parameter",
"Id": "ParamBreath",
"Segments": [
0,
0,
1,
0.189,
0,
0.378,
1,
0.567,
1,
1,
0.711,
1,
0.856,
0,
1,
0,
1,
1.222,
0,
1.444,
1,
1.667,
1,
1,
1.889,
1,
2.111,
0,
2.333,
0,
1,
2.544,
0,
2.756,
1,
2.967,
1,
1,
3.167,
1,
3.367,
0,
3.567,
0,
0,
4.433,
0
]
},
{
"Target": "Parameter",
"Id": "ParamShoulder",
"Segments": [
0,
0.1,
1,
0.467,
0.1,
0.933,
1,
1.4,
1,
1,
1.844,
1,
2.289,
1,
2.733,
1,
1,
2.967,
1,
3.2,
-1,
3.433,
-1,
0,
4.433,
-1
]
},
{
"Target": "Parameter",
"Id": "ParamLeg",
"Segments": [
0,
1,
0,
4.433,
1
]
},
{
"Target": "Parameter",
"Id": "ParamArmLA",
"Segments": [
0,
-10,
0,
4.433,
-10
]
},
{
"Target": "Parameter",
"Id": "ParamArmRA",
"Segments": [
0,
-10,
0,
4.433,
-10
]
},
{
"Target": "Parameter",
"Id": "ParamHairAhoge",
"Segments": [
0,
0,
1,
0.3,
0,
0.6,
0,
0.9,
-0.012,
1,
1.067,
-0.019,
1.233,
-6.827,
1.4,
-6.827,
1,
1.511,
-6.827,
1.622,
7.958,
1.733,
7.958,
1,
1.944,
7.958,
2.156,
-7.565,
2.367,
-7.565,
1,
2.5,
-7.565,
2.633,
9.434,
2.767,
9.434,
1,
2.978,
9.434,
3.189,
-8.871,
3.4,
-8.871,
1,
3.5,
-8.871,
3.6,
7.588,
3.7,
7.588,
1,
3.789,
7.588,
3.878,
-3.904,
3.967,
-3.904,
1,
4.011,
-3.904,
4.056,
-0.032,
4.1,
-0.032,
0,
4.433,
-0.032
]
},
{
"Target": "PartOpacity",
"Id": "PartArmA",
"Segments": [
0,
1,
0,
4.43,
1
]
},
{
"Target": "PartOpacity",
"Id": "PartArmB",
"Segments": [
0,
0,
0,
4.43,
0
]
}
]
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,927 @@
{
"Version": 3,
"Meta": {
"Duration": 1.9,
"Fps": 30.0,
"Loop": true,
"AreBeziersRestricted": false,
"CurveCount": 30,
"TotalSegmentCount": 121,
"TotalPointCount": 331,
"UserDataCount": 0,
"TotalUserDataSize": 0
},
"Curves": [
{
"Target": "Parameter",
"Id": "ParamAngleX",
"Segments": [
0,
0,
1,
0.111,
0,
0.222,
0,
0.333,
0,
0,
1.9,
0
]
},
{
"Target": "Parameter",
"Id": "ParamAngleY",
"Segments": [
0,
0,
1,
0.111,
0,
0.222,
0,
0.333,
0,
0,
1.9,
0
]
},
{
"Target": "Parameter",
"Id": "ParamAngleZ",
"Segments": [
0,
0,
1,
0.111,
0,
0.222,
0,
0.333,
0,
1,
0.444,
0,
0.556,
8,
0.667,
8,
0,
1.9,
8
]
},
{
"Target": "Parameter",
"Id": "ParamCheek",
"Segments": [
0,
0,
1,
0.111,
0,
0.222,
0,
0.333,
0,
0,
1.9,
0
]
},
{
"Target": "Parameter",
"Id": "ParamEyeLOpen",
"Segments": [
0,
1,
1,
0.111,
1,
0.222,
1,
0.333,
1,
1,
0.378,
1,
0.422,
0,
0.467,
0,
1,
0.522,
0,
0.578,
1.2,
0.633,
1.2,
1,
0.744,
1.2,
0.856,
1.2,
0.967,
1.2,
1,
0.989,
1.2,
1.011,
0,
1.033,
0,
1,
1.067,
0,
1.1,
1.2,
1.133,
1.2,
1,
1.167,
1.2,
1.2,
1.2,
1.233,
1.2,
1,
1.267,
1.2,
1.3,
0,
1.333,
0,
1,
1.356,
0,
1.378,
1.2,
1.4,
1.2,
0,
1.9,
1.2
]
},
{
"Target": "Parameter",
"Id": "ParamEyeLSmile",
"Segments": [
0,
0,
1,
0.111,
0,
0.222,
0,
0.333,
0,
0,
1.9,
0
]
},
{
"Target": "Parameter",
"Id": "ParamEyeROpen",
"Segments": [
0,
1,
1,
0.111,
1,
0.222,
1,
0.333,
1,
1,
0.378,
1,
0.422,
0,
0.467,
0,
1,
0.522,
0,
0.578,
1.2,
0.633,
1.2,
1,
0.744,
1.2,
0.856,
1.2,
0.967,
1.2,
1,
0.989,
1.2,
1.011,
0,
1.033,
0,
1,
1.067,
0,
1.1,
1.2,
1.133,
1.2,
1,
1.167,
1.2,
1.2,
1.2,
1.233,
1.2,
1,
1.267,
1.2,
1.3,
0,
1.333,
0,
1,
1.356,
0,
1.378,
1.2,
1.4,
1.2,
0,
1.9,
1.2
]
},
{
"Target": "Parameter",
"Id": "ParamEyeRSmile",
"Segments": [
0,
0,
1,
0.111,
0,
0.222,
0,
0.333,
0,
1,
0.356,
0,
0.378,
0,
0.4,
0,
1,
0.489,
0,
0.578,
0,
0.667,
0,
0,
1.9,
0
]
},
{
"Target": "Parameter",
"Id": "ParamEyeBallX",
"Segments": [
0,
0,
1,
0.111,
0,
0.222,
0,
0.333,
0,
1,
0.356,
0,
0.378,
0,
0.4,
0,
1,
0.489,
0,
0.578,
0,
0.667,
0,
0,
1.9,
0
]
},
{
"Target": "Parameter",
"Id": "ParamEyeBallY",
"Segments": [
0,
0,
1,
0.111,
0,
0.222,
0,
0.333,
0,
1,
0.356,
0,
0.378,
0,
0.4,
0,
1,
0.489,
0,
0.578,
0,
0.667,
0,
0,
1.9,
0
]
},
{
"Target": "Parameter",
"Id": "ParamBrowLY",
"Segments": [
0,
0,
1,
0.111,
0,
0.222,
0,
0.333,
0,
1,
0.356,
0,
0.378,
0,
0.4,
0,
1,
0.489,
0,
0.578,
0.26,
0.667,
0.26,
0,
1.9,
0.26
]
},
{
"Target": "Parameter",
"Id": "ParamBrowRY",
"Segments": [
0,
0,
1,
0.111,
0,
0.222,
0,
0.333,
0,
1,
0.356,
0,
0.378,
0,
0.4,
0,
1,
0.489,
0,
0.578,
0.36,
0.667,
0.36,
0,
1.9,
0.36
]
},
{
"Target": "Parameter",
"Id": "ParamBrowLX",
"Segments": [
0,
0,
1,
0.111,
0,
0.222,
0,
0.333,
0,
1,
0.356,
0,
0.378,
0,
0.4,
0,
1,
0.489,
0,
0.578,
0,
0.667,
0,
0,
1.9,
0
]
},
{
"Target": "Parameter",
"Id": "ParamBrowRX",
"Segments": [
0,
0,
1,
0.111,
0,
0.222,
0,
0.333,
0,
1,
0.356,
0,
0.378,
0,
0.4,
0,
1,
0.489,
0,
0.578,
-0.27,
0.667,
-0.27,
0,
1.9,
-0.27
]
},
{
"Target": "Parameter",
"Id": "ParamBrowLAngle",
"Segments": [
0,
0,
1,
0.111,
0,
0.222,
0,
0.333,
0,
1,
0.356,
0,
0.378,
0,
0.4,
0,
1,
0.489,
0,
0.578,
0.26,
0.667,
0.26,
0,
1.9,
0.26
]
},
{
"Target": "Parameter",
"Id": "ParamBrowRAngle",
"Segments": [
0,
0,
1,
0.111,
0,
0.222,
0,
0.333,
0,
1,
0.356,
0,
0.378,
0,
0.4,
0,
1,
0.489,
0,
0.578,
-0.03,
0.667,
-0.03,
0,
1.9,
-0.03
]
},
{
"Target": "Parameter",
"Id": "ParamBrowLForm",
"Segments": [
0,
0,
1,
0.111,
0,
0.222,
0,
0.333,
0,
1,
0.356,
0,
0.378,
0,
0.4,
0,
1,
0.489,
0,
0.578,
0.33,
0.667,
0.33,
0,
1.9,
0.33
]
},
{
"Target": "Parameter",
"Id": "ParamBrowRForm",
"Segments": [
0,
0,
1,
0.111,
0,
0.222,
0,
0.333,
0,
1,
0.356,
0,
0.378,
0,
0.4,
0,
1,
0.489,
0,
0.578,
0.21,
0.667,
0.21,
0,
1.9,
0.21
]
},
{
"Target": "Parameter",
"Id": "ParamBodyAngleX",
"Segments": [
0,
0,
1,
0.111,
0,
0.222,
0,
0.333,
0,
1,
0.444,
0,
0.556,
-6,
0.667,
-6,
0,
1.9,
-6
]
},
{
"Target": "Parameter",
"Id": "ParamBodyAngleY",
"Segments": [
0,
0,
1,
0.111,
0,
0.222,
0,
0.333,
0,
1,
0.422,
0,
0.511,
10,
0.6,
10,
1,
0.667,
10,
0.733,
-6,
0.8,
-6,
1,
0.833,
-6,
0.867,
5,
0.9,
5,
1,
1.011,
5,
1.122,
0,
1.233,
0,
0,
1.9,
0
]
},
{
"Target": "Parameter",
"Id": "ParamBodyAngleZ",
"Segments": [
0,
0,
1,
0.111,
0,
0.222,
0,
0.333,
0,
1,
0.444,
0,
0.556,
-3,
0.667,
-3,
0,
1.9,
-3
]
},
{
"Target": "Parameter",
"Id": "ParamBreath",
"Segments": [
0,
0,
1,
0.111,
0,
0.222,
0,
0.333,
0,
0,
1.9,
0
]
},
{
"Target": "Parameter",
"Id": "ParamShoulder",
"Segments": [
0,
-0.062,
1,
0.111,
-0.062,
0.222,
-0.103,
0.333,
0,
1,
0.589,
0.238,
0.844,
1,
1.1,
1,
0,
1.9,
1
]
},
{
"Target": "Parameter",
"Id": "ParamArmLA",
"Segments": [
0,
0,
1,
0.111,
0,
0.222,
0,
0.333,
0,
1,
0.478,
0,
0.622,
-10,
0.767,
-10,
1,
0.811,
-10,
0.856,
-8.2,
0.9,
-8.2,
0,
1.9,
-8.2
]
},
{
"Target": "Parameter",
"Id": "ParamArmRA",
"Segments": [
0,
0,
1,
0.111,
0,
0.222,
0,
0.333,
0,
1,
0.478,
0,
0.622,
-10,
0.767,
-10,
1,
0.811,
-10,
0.856,
-7.2,
0.9,
-7.2,
0,
1.9,
-7.2
]
},
{
"Target": "Parameter",
"Id": "ParamArmLB",
"Segments": [
0,
0,
1,
0.111,
0,
0.222,
0,
0.333,
0,
0,
1.9,
0
]
},
{
"Target": "Parameter",
"Id": "ParamArmRB",
"Segments": [
0,
0,
1,
0.111,
0,
0.222,
0,
0.333,
0,
0,
1.9,
0
]
},
{
"Target": "Parameter",
"Id": "ParamHairAhoge",
"Segments": [
0,
0,
1,
0.111,
0,
0.222,
1.9,
0.333,
5.2,
1,
0.444,
8.5,
0.556,
9.926,
0.667,
9.926,
1,
0.744,
9.926,
0.822,
-10,
0.9,
-10,
1,
0.956,
-10,
1.011,
6,
1.067,
6,
1,
1.144,
6,
1.222,
-4,
1.3,
-4,
1,
1.367,
-4,
1.433,
0,
1.5,
0,
0,
1.9,
0
]
},
{
"Target": "PartOpacity",
"Id": "PartArmA",
"Segments": [
0,
1,
0,
1.9,
1
]
},
{
"Target": "PartOpacity",
"Id": "PartArmB",
"Segments": [
0,
0,
0,
1.9,
0
]
}
]
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,924 @@
{
"Version": 3,
"Meta": {
"Duration": 4.17,
"Fps": 30.0,
"Loop": true,
"AreBeziersRestricted": false,
"CurveCount": 31,
"TotalSegmentCount": 118,
"TotalPointCount": 321,
"UserDataCount": 0,
"TotalUserDataSize": 0
},
"Curves": [
{
"Target": "Parameter",
"Id": "ParamAngleX",
"Segments": [
0,
0,
1,
0.067,
0,
0.133,
0,
0.2,
0,
1,
0.4,
0,
0.6,
0,
0.8,
0,
1,
1.067,
0,
1.333,
1.041,
1.6,
1.041,
1,
1.844,
1.041,
2.089,
-8,
2.333,
-8,
1,
2.656,
-8,
2.978,
6,
3.3,
6,
0,
4.167,
6
]
},
{
"Target": "Parameter",
"Id": "ParamAngleY",
"Segments": [
0,
0,
1,
0.067,
0,
0.133,
0,
0.2,
0,
1,
0.344,
0,
0.489,
-30,
0.633,
-30,
0,
4.167,
-30
]
},
{
"Target": "Parameter",
"Id": "ParamAngleZ",
"Segments": [
0,
0,
1,
0.067,
0,
0.133,
0,
0.2,
0,
0,
4.167,
0
]
},
{
"Target": "Parameter",
"Id": "ParamCheek",
"Segments": [
0,
0,
1,
0.067,
0,
0.133,
0,
0.2,
0,
0,
4.167,
0
]
},
{
"Target": "Parameter",
"Id": "ParamEyeLOpen",
"Segments": [
0,
1,
1,
0.067,
1,
0.133,
1,
0.2,
1,
1,
0.311,
1,
0.422,
0.988,
0.533,
0.8,
1,
0.589,
0.706,
0.644,
0,
0.7,
0,
1,
0.722,
0,
0.744,
0,
0.767,
0,
1,
0.822,
0,
0.878,
0.8,
0.933,
0.8,
1,
1.422,
0.8,
1.911,
0.8,
2.4,
0.8,
1,
2.456,
0.8,
2.511,
0,
2.567,
0,
1,
2.589,
0,
2.611,
0,
2.633,
0,
1,
2.689,
0,
2.744,
0.8,
2.8,
0.8,
0,
4.167,
0.8
]
},
{
"Target": "Parameter",
"Id": "ParamEyeLSmile",
"Segments": [
0,
0,
1,
0.067,
0,
0.133,
0,
0.2,
0,
0,
4.167,
0
]
},
{
"Target": "Parameter",
"Id": "ParamEyeROpen",
"Segments": [
0,
1,
1,
0.067,
1,
0.133,
1,
0.2,
1,
1,
0.311,
1,
0.422,
0.988,
0.533,
0.8,
1,
0.589,
0.706,
0.644,
0,
0.7,
0,
1,
0.722,
0,
0.744,
0,
0.767,
0,
1,
0.822,
0,
0.878,
0.8,
0.933,
0.8,
1,
1.422,
0.8,
1.911,
0.8,
2.4,
0.8,
1,
2.456,
0.8,
2.511,
0,
2.567,
0,
1,
2.589,
0,
2.611,
0,
2.633,
0,
1,
2.689,
0,
2.744,
0.8,
2.8,
0.8,
0,
4.167,
0.8
]
},
{
"Target": "Parameter",
"Id": "ParamEyeRSmile",
"Segments": [
0,
0,
1,
0.067,
0,
0.133,
0,
0.2,
0,
1,
0.278,
0,
0.356,
0,
0.433,
0,
0,
4.167,
0
]
},
{
"Target": "Parameter",
"Id": "ParamEyeBallX",
"Segments": [
0,
0,
1,
0.067,
0,
0.133,
0,
0.2,
0,
1,
0.278,
0,
0.356,
0,
0.433,
0,
1,
0.667,
0,
0.9,
0.004,
1.133,
-0.01,
1,
1.4,
-0.025,
1.667,
-0.43,
1.933,
-0.43,
1,
2.211,
-0.43,
2.489,
0.283,
2.767,
0.283,
0,
4.167,
0.283
]
},
{
"Target": "Parameter",
"Id": "ParamEyeBallY",
"Segments": [
0,
0,
1,
0.067,
0,
0.133,
0,
0.2,
0,
1,
0.278,
0,
0.356,
-1,
0.433,
-1,
0,
4.167,
-1
]
},
{
"Target": "Parameter",
"Id": "ParamBrowLY",
"Segments": [
0,
0,
1,
0.067,
0,
0.133,
0,
0.2,
0,
1,
0.278,
0,
0.356,
0.19,
0.433,
0.19,
0,
4.167,
0.19
]
},
{
"Target": "Parameter",
"Id": "ParamBrowRY",
"Segments": [
0,
0,
1,
0.067,
0,
0.133,
0,
0.2,
0,
1,
0.278,
0,
0.356,
0.11,
0.433,
0.11,
0,
4.167,
0.11
]
},
{
"Target": "Parameter",
"Id": "ParamBrowLX",
"Segments": [
0,
0,
1,
0.067,
0,
0.133,
0,
0.2,
0,
1,
0.278,
0,
0.356,
-0.48,
0.433,
-0.48,
0,
4.167,
-0.48
]
},
{
"Target": "Parameter",
"Id": "ParamBrowRX",
"Segments": [
0,
0,
1,
0.067,
0,
0.133,
0,
0.2,
0,
1,
0.278,
0,
0.356,
-0.29,
0.433,
-0.29,
0,
4.167,
-0.29
]
},
{
"Target": "Parameter",
"Id": "ParamBrowLAngle",
"Segments": [
0,
0,
1,
0.067,
0,
0.133,
0,
0.2,
0,
1,
0.278,
0,
0.356,
1,
0.433,
1,
0,
4.167,
1
]
},
{
"Target": "Parameter",
"Id": "ParamBrowRAngle",
"Segments": [
0,
0,
1,
0.067,
0,
0.133,
0,
0.2,
0,
1,
0.278,
0,
0.356,
0.85,
0.433,
0.85,
0,
4.167,
0.85
]
},
{
"Target": "Parameter",
"Id": "ParamBrowLForm",
"Segments": [
0,
0,
1,
0.067,
0,
0.133,
0,
0.2,
0,
1,
0.278,
0,
0.356,
-0.75,
0.433,
-0.75,
0,
4.167,
-0.75
]
},
{
"Target": "Parameter",
"Id": "ParamBrowRForm",
"Segments": [
0,
0,
1,
0.067,
0,
0.133,
0,
0.2,
0,
1,
0.278,
0,
0.356,
-0.87,
0.433,
-0.87,
0,
4.167,
-0.87
]
},
{
"Target": "Parameter",
"Id": "ParamBodyAngleX",
"Segments": [
0,
0,
1,
0.067,
0,
0.133,
0,
0.2,
0,
1,
0.444,
0,
0.689,
0,
0.933,
0,
1,
1.211,
0,
1.489,
0,
1.767,
0,
1,
2.056,
0,
2.344,
-6,
2.633,
-6,
1,
3.033,
-6,
3.433,
10,
3.833,
10,
0,
4.167,
10
]
},
{
"Target": "Parameter",
"Id": "ParamBodyAngleY",
"Segments": [
0,
0,
1,
0.067,
0,
0.133,
0,
0.2,
0,
0,
4.167,
0
]
},
{
"Target": "Parameter",
"Id": "ParamBodyAngleZ",
"Segments": [
0,
0,
1,
0.067,
0,
0.133,
0,
0.2,
0,
1,
0.8,
0,
1.4,
-2,
2,
-2,
1,
2.456,
-2,
2.911,
8.125,
3.367,
8.125,
0,
4.167,
8.125
]
},
{
"Target": "Parameter",
"Id": "ParamBreath",
"Segments": [
0,
0,
1,
0.067,
0,
0.133,
0,
0.2,
0,
0,
4.167,
0
]
},
{
"Target": "Parameter",
"Id": "ParamShoulder",
"Segments": [
0,
0,
0,
4.167,
0
]
},
{
"Target": "Parameter",
"Id": "ParamLeg",
"Segments": [
0,
1,
1,
0.667,
1,
1.333,
1,
2,
1,
1,
2.267,
1,
2.533,
0.948,
2.8,
0.948,
0,
4.167,
0.948
]
},
{
"Target": "Parameter",
"Id": "ParamArmLA",
"Segments": [
0,
0,
1,
0.067,
0,
0.133,
0,
0.2,
0,
1,
0.233,
0,
0.267,
0,
0.3,
0,
1,
0.478,
0,
0.656,
-10,
0.833,
-10,
1,
0.922,
-10,
1.011,
-8.846,
1.1,
-8.846,
1,
1.467,
-8.846,
1.833,
-8.835,
2.2,
-9.1,
1,
2.622,
-9.405,
3.044,
-10,
3.467,
-10,
0,
4.167,
-10
]
},
{
"Target": "Parameter",
"Id": "ParamArmRA",
"Segments": [
0,
0,
1,
0.067,
0,
0.133,
0,
0.2,
0,
1,
0.233,
0,
0.267,
0,
0.3,
0,
1,
0.478,
0,
0.656,
-10,
0.833,
-10,
1,
0.922,
-10,
1.011,
-8.972,
1.1,
-8.846,
1,
1.467,
-8.328,
1.833,
-8.2,
2.2,
-8.2,
1,
2.622,
-8.2,
3.044,
-10,
3.467,
-10,
0,
4.167,
-10
]
},
{
"Target": "Parameter",
"Id": "ParamArmLB",
"Segments": [
0,
0,
0,
4.167,
0
]
},
{
"Target": "Parameter",
"Id": "ParamArmRB",
"Segments": [
0,
0,
0,
4.167,
0
]
},
{
"Target": "Parameter",
"Id": "ParamHairAhoge",
"Segments": [
0,
0,
1,
0.067,
0,
0.133,
0,
0.2,
0,
1,
0.233,
0,
0.267,
-5,
0.3,
-5,
1,
0.378,
-5,
0.456,
10,
0.533,
10,
1,
0.633,
10,
0.733,
4,
0.833,
4,
0,
4.167,
4
]
},
{
"Target": "PartOpacity",
"Id": "PartArmA",
"Segments": [
0,
1,
0,
4.17,
1
]
},
{
"Target": "PartOpacity",
"Id": "PartArmB",
"Segments": [
0,
0,
0,
4.17,
0
]
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

+81 -19
View File
@@ -1,50 +1,112 @@
// 主应用入口
import { log } from './utils/logger.js';
import { checkOpusLoaded, initOpusEncoder } from './core/audio/opus-codec.js';
import { getUIController } from './ui/controller.js';
import { getAudioPlayer } from './core/audio/player.js';
import { initMcpTools } from './core/mcp/tools.js';
import { checkOpusLoaded, initOpusEncoder } from './core/audio/opus-codec.js?v=0127';
import { getAudioPlayer } from './core/audio/player.js?v=0127';
import { checkMicrophoneAvailability, isHttpNonLocalhost } from './core/audio/recorder.js?v=0127';
import { initMcpTools } from './core/mcp/tools.js?v=0127';
import { uiController } from './ui/controller.js?v=0127';
import { log } from './utils/logger.js?v=0127';
// 应用类
class App {
constructor() {
this.uiController = null;
this.audioPlayer = null;
this.live2dManager = null;
}
// 初始化应用
async init() {
log('正在初始化应用...', 'info');
// 初始化UI控制器
this.uiController = getUIController();
this.uiController = uiController;
this.uiController.init();
// 检查Opus库
checkOpusLoaded();
// 初始化Opus编码器
initOpusEncoder();
// 初始化音频播放器
this.audioPlayer = getAudioPlayer();
await this.audioPlayer.start();
// 初始化MCP工具
initMcpTools();
// 检查麦克风可用性
await this.checkMicrophoneAvailability();
// 初始化Live2D
await this.initLive2D();
// 关闭加载loading
this.setModelLoadingStatus(false);
log('应用初始化完成', 'success');
}
// 初始化Live2D
async initLive2D() {
try {
// 检查Live2DManager是否已加载
if (typeof window.Live2DManager === 'undefined') {
throw new Error('Live2DManager未加载,请检查脚本引入顺序');
}
this.live2dManager = new window.Live2DManager();
await this.live2dManager.initializeLive2D();
// 更新UI状态
const live2dStatus = document.getElementById('live2dStatus');
if (live2dStatus) {
live2dStatus.textContent = '● 已加载';
live2dStatus.className = 'status loaded';
}
log('Live2D初始化完成', 'success');
} catch (error) {
log(`Live2D初始化失败: ${error.message}`, 'error');
// 更新UI状态
const live2dStatus = document.getElementById('live2dStatus');
if (live2dStatus) {
live2dStatus.textContent = '● 加载失败';
live2dStatus.className = 'status error';
}
}
}
// 设置model加载状态
setModelLoadingStatus(isLoading) {
const modelLoading = document.getElementById('modelLoading');
if (modelLoading) {
modelLoading.style.display = isLoading ? 'flex' : 'none';
}
}
/**
* 检查麦克风可用性
* 在应用初始化时调用,检查麦克风是否可用并更新UI状态
*/
async checkMicrophoneAvailability() {
try {
const isAvailable = await checkMicrophoneAvailability();
const isHttp = isHttpNonLocalhost();
// 保存可用性状态到全局变量
window.microphoneAvailable = isAvailable;
window.isHttpNonLocalhost = isHttp;
// 更新UI
if (this.uiController) {
this.uiController.updateMicrophoneAvailability(isAvailable, isHttp);
}
log(`麦克风可用性检查完成: ${isAvailable ? '可用' : '不可用'}`, isAvailable ? 'success' : 'warning');
} catch (error) {
log(`检查麦克风可用性失败: ${error.message}`, 'error');
// 默认设置为不可用
window.microphoneAvailable = false;
window.isHttpNonLocalhost = isHttpNonLocalhost();
if (this.uiController) {
this.uiController.updateMicrophoneAvailability(false, window.isHttpNonLocalhost);
}
}
}
}
// 创建并启动应用
const app = new App();
// DOM加载完成后初始化
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => app.init());
} else {
// 将应用实例暴露到全局,供其他模块访问
window.chatApp = app;
document.addEventListener('DOMContentLoaded', () => {
// 初始化应用
app.init();
}
});
export default app;
@@ -58,12 +58,16 @@ export function saveConfig() {
// 获取配置值
export function getConfig() {
const deviceMac = document.getElementById('deviceMac').value.trim();
// 从DOM获取值
const deviceMac = document.getElementById('deviceMac')?.value.trim() || '';
const deviceName = document.getElementById('deviceName')?.value.trim() || '';
const clientId = document.getElementById('clientId')?.value.trim() || '';
return {
deviceId: deviceMac, // 使用MAC地址作为deviceId
deviceName: document.getElementById('deviceName').value.trim(),
deviceMac: deviceMac,
clientId: document.getElementById('clientId').value.trim()
deviceName,
deviceMac,
clientId
};
}
@@ -1,5 +1,4 @@
import { log } from '../../utils/logger.js';
import { updateScriptStatus } from '../../ui/dom-helper.js'
import { log } from '../../utils/logger.js?v=0127';
// 检查Opus库是否已加载
@@ -15,7 +14,6 @@ export function checkOpusLoaded() {
// 使用Module.instance对象替换全局Module对象
window.ModuleInstance = Module.instance;
log('Opus库加载成功(使用Module.instance', 'success');
updateScriptStatus('Opus库加载成功', 'success');
// 3秒后隐藏状态
const statusElement = document.getElementById('scriptStatus');
@@ -27,7 +25,6 @@ export function checkOpusLoaded() {
if (typeof Module._opus_decoder_get_size === 'function') {
window.ModuleInstance = Module;
log('Opus库加载成功(使用全局Module', 'success');
updateScriptStatus('Opus库加载成功', 'success');
// 3秒后隐藏状态
const statusElement = document.getElementById('scriptStatus');
@@ -38,7 +35,6 @@ export function checkOpusLoaded() {
throw new Error('Opus解码函数未找到,可能Module结构不正确');
} catch (err) {
log(`Opus库加载失败,请检查libopus.js文件是否存在且正确: ${err.message}`, 'error');
updateScriptStatus('Opus库加载失败,请检查libopus.js文件是否存在且正确', 'error');
}
}
@@ -1,7 +1,7 @@
// 音频播放模块
import { log } from '../../utils/logger.js';
import BlockingQueue from '../../utils/blocking-queue.js';
import { createStreamingContext } from './stream-context.js';
import BlockingQueue from '../../utils/blocking-queue.js?v=0127';
import { log } from '../../utils/logger.js?v=0127';
import { createStreamingContext } from './stream-context.js?v=0127';
// 音频播放器类
export class AudioPlayer {
@@ -1,9 +1,9 @@
// 音频录制模块
import { log } from '../../utils/logger.js';
import { initOpusEncoder } from './opus-codec.js';
import { getAudioPlayer } from './player.js';
// Audio recording module
import { log } from '../../utils/logger.js?v=0127';
import { initOpusEncoder } from './opus-codec.js?v=0127';
import { getAudioPlayer } from './player.js?v=0127';
// 音频录制器类
// Audio recorder class
export class AudioRecorder {
constructor() {
this.isRecording = false;
@@ -19,25 +19,23 @@ export class AudioRecorder {
this.visualizationRequest = null;
this.recordingTimer = null;
this.websocket = null;
// 回调函数
// Callback functions
this.onRecordingStart = null;
this.onRecordingStop = null;
this.onVisualizerUpdate = null;
}
// 设置WebSocket实例
// Set WebSocket instance
setWebSocket(ws) {
this.websocket = ws;
}
// 获取AudioContext实例
// Get AudioContext instance
getAudioContext() {
const audioPlayer = getAudioPlayer();
return audioPlayer.getAudioContext();
return getAudioPlayer().getAudioContext();
}
// 初始化编码器
// Initialize encoder
initEncoder() {
if (!this.opusEncoder) {
this.opusEncoder = initOpusEncoder();
@@ -45,7 +43,7 @@ export class AudioRecorder {
return this.opusEncoder;
}
// PCM处理器代码
// PCM processor code
getAudioProcessorCode() {
return `
class AudioRecorderProcessor extends AudioWorkletProcessor {
@@ -56,166 +54,132 @@ export class AudioRecorder {
this.buffer = new Int16Array(this.frameSize);
this.bufferIndex = 0;
this.isRecording = false;
this.port.onmessage = (event) => {
if (event.data.command === 'start') {
this.isRecording = true;
this.port.postMessage({ type: 'status', status: 'started' });
} else if (event.data.command === 'stop') {
this.isRecording = false;
if (this.bufferIndex > 0) {
const finalBuffer = this.buffer.slice(0, this.bufferIndex);
this.port.postMessage({
type: 'buffer',
buffer: finalBuffer
});
this.port.postMessage({ type: 'buffer', buffer: finalBuffer });
this.bufferIndex = 0;
}
this.port.postMessage({ type: 'status', status: 'stopped' });
}
};
}
process(inputs, outputs, parameters) {
if (!this.isRecording) return true;
const input = inputs[0][0];
if (!input) return true;
for (let i = 0; i < input.length; i++) {
if (this.bufferIndex >= this.frameSize) {
this.port.postMessage({
type: 'buffer',
buffer: this.buffer.slice(0)
});
this.port.postMessage({ type: 'buffer', buffer: this.buffer.slice(0) });
this.bufferIndex = 0;
}
this.buffer[this.bufferIndex++] = Math.max(-32768, Math.min(32767, Math.floor(input[i] * 32767)));
}
return true;
}
}
registerProcessor('audio-recorder-processor', AudioRecorderProcessor);
`;
}
// 创建音频处理器
// Create audio processor
async createAudioProcessor() {
this.audioContext = this.getAudioContext();
try {
if (this.audioContext.audioWorklet) {
const blob = new Blob([this.getAudioProcessorCode()], { type: 'application/javascript' });
const url = URL.createObjectURL(blob);
await this.audioContext.audioWorklet.addModule(url);
URL.revokeObjectURL(url);
const audioProcessor = new AudioWorkletNode(this.audioContext, 'audio-recorder-processor');
audioProcessor.port.onmessage = (event) => {
if (event.data.type === 'buffer') {
this.processPCMBuffer(event.data.buffer);
}
};
log('使用AudioWorklet处理音频', 'success');
const silent = this.audioContext.createGain();
silent.gain.value = 0;
audioProcessor.connect(silent);
silent.connect(this.audioContext.destination);
return { node: audioProcessor, type: 'worklet' };
} else {
log('AudioWorklet不可用,使用ScriptProcessorNode作为回退方案', 'warning');
log('AudioWorklet不可用,使用ScriptProcessorNode作为后备方案', 'warning');
return this.createScriptProcessor();
}
} catch (error) {
log(`创建音频处理器失败: ${error.message},尝试回退方案`, 'error');
log(`创建音频处理器失败: ${error.message},尝试后备方案`, 'error');
return this.createScriptProcessor();
}
}
// 创建ScriptProcessor作为回退
// Create ScriptProcessor as fallback
createScriptProcessor() {
try {
const frameSize = 4096;
const scriptProcessor = this.audioContext.createScriptProcessor(frameSize, 1, 1);
scriptProcessor.onaudioprocess = (event) => {
if (!this.isRecording) return;
const input = event.inputBuffer.getChannelData(0);
const buffer = new Int16Array(input.length);
for (let i = 0; i < input.length; i++) {
buffer[i] = Math.max(-32768, Math.min(32767, Math.floor(input[i] * 32767)));
}
this.processPCMBuffer(buffer);
};
const silent = this.audioContext.createGain();
silent.gain.value = 0;
scriptProcessor.connect(silent);
silent.connect(this.audioContext.destination);
log('使用ScriptProcessorNode作为回退方案成功', 'warning');
log('使用ScriptProcessorNode作为后备方案成功', 'warning');
return { node: scriptProcessor, type: 'processor' };
} catch (fallbackError) {
log(`回退方案也失败: ${fallbackError.message}`, 'error');
log(`后备方案也失败: ${fallbackError.message}`, 'error');
return null;
}
}
// 处理PCM缓冲数据
// Process PCM buffer data
processPCMBuffer(buffer) {
if (!this.isRecording) return;
const newBuffer = new Int16Array(this.pcmDataBuffer.length + buffer.length);
newBuffer.set(this.pcmDataBuffer);
newBuffer.set(buffer, this.pcmDataBuffer.length);
this.pcmDataBuffer = newBuffer;
const samplesPerFrame = 960;
while (this.pcmDataBuffer.length >= samplesPerFrame) {
const frameData = this.pcmDataBuffer.slice(0, samplesPerFrame);
this.pcmDataBuffer = this.pcmDataBuffer.slice(samplesPerFrame);
this.encodeAndSendOpus(frameData);
}
}
// 编码并发送Opus数据
// Encode and send Opus data
encodeAndSendOpus(pcmData = null) {
if (!this.opusEncoder) {
log('Opus编码器未初始化', 'error');
return;
}
try {
if (pcmData) {
const opusData = this.opusEncoder.encode(pcmData);
if (opusData && opusData.length > 0) {
this.audioBuffers.push(opusData.buffer);
this.totalAudioSize += opusData.length;
if (this.websocket && this.websocket.readyState === WebSocket.OPEN) {
try {
this.websocket.send(opusData.buffer);
log(`发送Opus帧,大小:${opusData.length}字节`, 'debug');
} catch (error) {
log(`WebSocket发送错误: ${error.message}`, 'error');
}
}
} else {
log('Opus编码失败,有效数据返回', 'error');
log('Opus编码失败,未返回有效数据', 'error');
}
} else {
if (this.pcmDataBuffer.length > 0) {
@@ -235,98 +199,67 @@ export class AudioRecorder {
}
}
// 开始录音
// Start recording
async start() {
if (this.isRecording) return false;
try {
// 检查是否有WebSocketHandler实例
const { getWebSocketHandler } = await import('../network/websocket.js');
// Check if WebSocketHandler instance exists
const { getWebSocketHandler } = await import('../network/websocket.js?v=0127');
const wsHandler = getWebSocketHandler();
// 如果机器正在说话,发送打断消息
// If machine is speaking, send abort message
if (wsHandler && wsHandler.isRemoteSpeaking && wsHandler.currentSessionId) {
const abortMessage = {
session_id: wsHandler.currentSessionId,
type: 'abort',
reason: 'wake_word_detected'
};
const abortMessage = { session_id: wsHandler.currentSessionId, type: 'abort', reason: 'wake_word_detected' };
if (this.websocket && this.websocket.readyState === WebSocket.OPEN) {
this.websocket.send(JSON.stringify(abortMessage));
log('发送打断消息', 'info');
log('发送中止消息', 'info');
}
}
if (!this.initEncoder()) {
log('无法启动录音: Opus编码器初始化失败', 'error');
log('无法开始录音: Opus编码器初始化失败', 'error');
return false;
}
log('请至少录制1-2秒钟的音频,确保采集到足够数据', 'info');
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
sampleRate: 16000,
channelCount: 1
}
});
log('请至少录制1-2秒音频以确保收集足够的数据', 'info');
const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, sampleRate: 16000, channelCount: 1 } });
this.audioContext = this.getAudioContext();
if (this.audioContext.state === 'suspended') {
await this.audioContext.resume();
}
const processorResult = await this.createAudioProcessor();
if (!processorResult) {
log('无法创建音频处理器', 'error');
return false;
}
this.audioProcessor = processorResult.node;
this.audioProcessorType = processorResult.type;
this.audioSource = this.audioContext.createMediaStreamSource(stream);
this.analyser = this.audioContext.createAnalyser();
this.analyser.fftSize = 2048;
this.audioSource.connect(this.analyser);
this.audioSource.connect(this.audioProcessor);
this.pcmDataBuffer = new Int16Array();
this.audioBuffers = [];
this.totalAudioSize = 0;
this.isRecording = true;
if (this.audioProcessorType === 'worklet' && this.audioProcessor.port) {
this.audioProcessor.port.postMessage({ command: 'start' });
}
// 发送监听开始消息
// Send listening start message
if (this.websocket && this.websocket.readyState === WebSocket.OPEN) {
const listenMessage = {
type: 'listen',
mode: 'manual',
state: 'start'
};
log(`发送录音开始消息: ${JSON.stringify(listenMessage)}`, 'info');
this.websocket.send(JSON.stringify(listenMessage));
log(`已发送录音开始消息`, 'info');
} else {
log('WebSocket未连接,无法发送开始消息', 'error');
return false;
}
// 开始可视化
// Start visualization
if (this.onVisualizerUpdate) {
const dataArray = new Uint8Array(this.analyser.frequencyBinCount);
this.startVisualization(dataArray);
}
// 启动录音计时器
// Immediately notify recording start, update button state
if (this.onRecordingStart) {
this.onRecordingStart(0);
}
// Start recording timer
let recordingSeconds = 0;
this.recordingTimer = setInterval(() => {
recordingSeconds += 0.1;
@@ -334,8 +267,7 @@ export class AudioRecorder {
this.onRecordingStart(recordingSeconds);
}
}, 100);
log('开始PCM直接录音', 'success');
log('已开始PCM直接录音', 'success');
return true;
} catch (error) {
log(`直接录音启动错误: ${error.message}`, 'error');
@@ -344,15 +276,12 @@ export class AudioRecorder {
}
}
// 开始可视化
// Start visualization
startVisualization(dataArray) {
const draw = () => {
this.visualizationRequest = requestAnimationFrame(() => draw());
if (!this.isRecording) return;
this.analyser.getByteFrequencyData(dataArray);
if (this.onVisualizerUpdate) {
this.onVisualizerUpdate(dataArray);
}
@@ -360,60 +289,42 @@ export class AudioRecorder {
draw();
}
// 停止录音
// Stop recording
stop() {
if (!this.isRecording) return false;
try {
this.isRecording = false;
if (this.audioProcessor) {
if (this.audioProcessorType === 'worklet' && this.audioProcessor.port) {
this.audioProcessor.port.postMessage({ command: 'stop' });
}
this.audioProcessor.disconnect();
this.audioProcessor = null;
}
if (this.audioSource) {
this.audioSource.disconnect();
this.audioSource = null;
}
if (this.visualizationRequest) {
cancelAnimationFrame(this.visualizationRequest);
this.visualizationRequest = null;
}
if (this.recordingTimer) {
clearInterval(this.recordingTimer);
this.recordingTimer = null;
}
// 编码并发送剩余的数据
// Encode and send remaining data
this.encodeAndSendOpus();
// 发送结束信号
// Send end signal
if (this.websocket && this.websocket.readyState === WebSocket.OPEN) {
const emptyOpusFrame = new Uint8Array(0);
this.websocket.send(emptyOpusFrame);
const stopMessage = {
type: 'listen',
mode: 'manual',
state: 'stop'
};
this.websocket.send(JSON.stringify(stopMessage));
log('已发送录音停止信号', 'info');
}
if (this.onRecordingStop) {
this.onRecordingStop();
}
log('停止PCM直接录音', 'success');
log('已停止PCM直接录音', 'success');
return true;
} catch (error) {
log(`直接录音停止错误: ${error.message}`, 'error');
@@ -421,13 +332,13 @@ export class AudioRecorder {
}
}
// 获取分析器
// Get analyser
getAnalyser() {
return this.analyser;
}
}
// 创建单例
// Create singleton instance
let audioRecorderInstance = null;
export function getAudioRecorder() {
@@ -436,3 +347,49 @@ export function getAudioRecorder() {
}
return audioRecorderInstance;
}
/**
* Check if microphone is available
* @returns {Promise<boolean>} Returns true if available, false if not available
*/
export async function checkMicrophoneAvailability() {
// Check if browser supports getUserMedia API
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
log('浏览器不支持getUserMedia API', 'warning');
return false;
}
try {
// Try to access microphone
const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, sampleRate: 16000, channelCount: 1 } });
// Immediately stop all tracks to release microphone
stream.getTracks().forEach(track => track.stop());
log('麦克风可用性检查成功', 'success');
return true;
} catch (error) {
log(`麦克风不可用: ${error.message}`, 'warning');
return false;
}
}
/**
* Check if it is HTTP non-localhost access
* @returns {boolean} Returns true if it is HTTP non-localhost access
*/
export function isHttpNonLocalhost() {
const protocol = window.location.protocol;
const hostname = window.location.hostname;
// Check if it is HTTP protocol
if (protocol !== 'http:') {
return false;
}
// localhost and 127.0.0.1 can use microphone
if (hostname === 'localhost' || hostname === '127.0.0.1') {
return false;
}
// Private IP addresses can also use microphone (browser allows)
if (hostname.startsWith('192.168.') || hostname.startsWith('10.') || hostname.startsWith('172.')) {
return false;
}
// Other HTTP access is considered non-localhost
return true;
}
@@ -1,5 +1,5 @@
import BlockingQueue from '../../utils/blocking-queue.js';
import { log } from '../../utils/logger.js';
import BlockingQueue from '../../utils/blocking-queue.js?v=0127';
import { log } from '../../utils/logger.js?v=0127';
// 音频流播放上下文类
export class StreamingContext {
@@ -23,6 +23,10 @@ export class StreamingContext {
this.totalSamples = 0; // 累积的总样本数
this.lastPlayTime = 0; // 上次播放的时间戳
this.scheduledEndTime = 0; // 已调度音频的结束时间
// 初始化分析器节点(供Live2D使用)
this.analyser = this.audioContext.createAnalyser();
this.analyser.fftSize = 256;
}
// 缓存音频数组
@@ -108,6 +112,11 @@ export class StreamingContext {
log('音频缓冲已清空', 'success');
}
// 获取分析器节点(供Live2D使用)
getAnalyser() {
return this.analyser;
}
// 将Opus数据解码为PCM
async decodeOpusFrames() {
if (!this.opusDecoder) {
@@ -182,7 +191,8 @@ export class StreamingContext {
const currentTime = this.audioContext.currentTime;
const startTime = Math.max(this.scheduledEndTime, currentTime);
// 直接连接到输出
// 连接到分析器和输出
this.source.connect(this.analyser);
this.source.connect(this.audioContext.destination);
log(`调度播放 ${currentSamples.length} 个样本,约 ${(currentSamples.length / this.sampleRate).toFixed(2)}`, 'debug');
+30 -80
View File
@@ -1,4 +1,4 @@
import { log } from '../../utils/logger.js';
import { log } from '../../utils/logger.js?v=0127';
// ==========================================
// MCP 工具管理逻辑
@@ -24,7 +24,6 @@ export function setWebSocket(ws) {
export async function initMcpTools() {
// 加载默认工具数据
const defaultMcpTools = await fetch("js/config/default-mcp-tools.json").then(res => res.json());
const savedTools = localStorage.getItem('mcpTools');
if (savedTools) {
try {
@@ -36,9 +35,11 @@ export async function initMcpTools() {
} else {
mcpTools = [...defaultMcpTools];
}
renderMcpTools();
setupMcpEventListeners();
// Only setup event listeners if DOM elements exist
if (document.getElementById('toggleMcpTools')) {
setupMcpEventListeners();
}
}
/**
@@ -47,30 +48,29 @@ export async function initMcpTools() {
function renderMcpTools() {
const container = document.getElementById('mcpToolsContainer');
const countSpan = document.getElementById('mcpToolsCount');
countSpan.textContent = `${mcpTools.length} 个工具`;
if (!container) {
return; // Container not found, skip rendering
}
if (countSpan) {
countSpan.textContent = `${mcpTools.length} 个工具`;
}
if (mcpTools.length === 0) {
container.innerHTML = '<div style="text-align: center; padding: 30px; color: #999;">暂无工具,点击下方按钮添加新工具</div>';
return;
}
container.innerHTML = mcpTools.map((tool, index) => {
const paramCount = tool.inputSchema.properties ? Object.keys(tool.inputSchema.properties).length : 0;
const requiredCount = tool.inputSchema.required ? tool.inputSchema.required.length : 0;
const hasMockResponse = tool.mockResponse && Object.keys(tool.mockResponse).length > 0;
return `
<div class="mcp-tool-card">
<div class="mcp-tool-header">
<div class="mcp-tool-name">${tool.name}</div>
<div class="mcp-tool-actions">
<button onclick="window.mcpModule.editMcpTool(${index})"
style="padding: 4px 10px; border: none; border-radius: 4px; background-color: #2196f3; color: white; cursor: pointer; font-size: 12px;">
<button class="mcp-edit-btn" onclick="window.mcpModule.editMcpTool(${index})">
✏️ 编辑
</button>
<button onclick="window.mcpModule.deleteMcpTool(${index})"
style="padding: 4px 10px; border: none; border-radius: 4px; background-color: #f44336; color: white; cursor: pointer; font-size: 12px;">
<button class="mcp-delete-btn" onclick="window.mcpModule.deleteMcpTool(${index})">
🗑️ 删除
</button>
</div>
@@ -96,12 +96,13 @@ function renderMcpTools() {
*/
function renderMcpProperties() {
const container = document.getElementById('mcpPropertiesContainer');
if (!container) {
return; // Container not found, skip rendering
}
if (mcpProperties.length === 0) {
container.innerHTML = '<div style="text-align: center; padding: 20px; color: #999; font-size: 14px;">暂无参数,点击下方按钮添加参数</div>';
return;
}
container.innerHTML = mcpProperties.map((prop, index) => `
<div class="mcp-property-item">
<div class="mcp-property-header">
@@ -161,12 +162,7 @@ function renderMcpProperties() {
* 添加参数
*/
function addMcpProperty() {
mcpProperties.push({
name: `param_${mcpProperties.length + 1}`,
type: 'string',
required: false,
description: ''
});
mcpProperties.push({ name: `param_${mcpProperties.length + 1}`, type: 'string', required: false, description: '' });
renderMcpProperties();
}
@@ -182,9 +178,7 @@ function updateMcpProperty(index, field, value) {
return;
}
}
mcpProperties[index][field] = value;
if (field === 'type' && value !== 'integer' && value !== 'number') {
delete mcpProperties[index].minimum;
delete mcpProperties[index].maximum;
@@ -212,22 +206,24 @@ function setupMcpEventListeners() {
const cancelBtn = document.getElementById('cancelMcpBtn');
const form = document.getElementById('mcpToolForm');
const addPropertyBtn = document.getElementById('addMcpPropertyBtn');
// Return early if required elements don't exist (e.g., in test environment)
if (!toggleBtn || !panel || !addBtn || !modal || !closeBtn || !cancelBtn || !form || !addPropertyBtn) {
return;
}
toggleBtn.addEventListener('click', () => {
const isExpanded = panel.classList.contains('expanded');
panel.classList.toggle('expanded');
toggleBtn.textContent = isExpanded ? '展开' : '收起';
toggleBtn.textContent = isExpanded ? '收起' : '展开';
});
// 确保面板默认展开
panel.classList.add('expanded');
addBtn.addEventListener('click', () => openMcpModal());
closeBtn.addEventListener('click', closeMcpModal);
cancelBtn.addEventListener('click', closeMcpModal);
addPropertyBtn.addEventListener('click', addMcpProperty);
modal.addEventListener('click', (e) => {
if (e.target === modal) closeMcpModal();
});
form.addEventListener('submit', handleMcpSubmit);
}
@@ -240,18 +236,15 @@ function openMcpModal(index = null) {
alert('WebSocket 已连接,无法编辑工具');
return;
}
mcpEditingIndex = index;
const errorContainer = document.getElementById('mcpErrorContainer');
errorContainer.innerHTML = '';
if (index !== null) {
document.getElementById('mcpModalTitle').textContent = '编辑工具';
const tool = mcpTools[index];
document.getElementById('mcpToolName').value = tool.name;
document.getElementById('mcpToolDescription').value = tool.description;
document.getElementById('mcpMockResponse').value = tool.mockResponse ? JSON.stringify(tool.mockResponse, null, 2) : '';
mcpProperties = [];
const schema = tool.inputSchema;
if (schema.properties) {
@@ -272,7 +265,6 @@ function openMcpModal(index = null) {
document.getElementById('mcpToolForm').reset();
mcpProperties = [];
}
renderMcpProperties();
document.getElementById('mcpToolModal').style.display = 'block';
}
@@ -295,21 +287,15 @@ function handleMcpSubmit(e) {
e.preventDefault();
const errorContainer = document.getElementById('mcpErrorContainer');
errorContainer.innerHTML = '';
const name = document.getElementById('mcpToolName').value.trim();
const description = document.getElementById('mcpToolDescription').value.trim();
const mockResponseText = document.getElementById('mcpMockResponse').value.trim();
// 检查名称重复
const isDuplicate = mcpTools.some((tool, index) =>
tool.name === name && index !== mcpEditingIndex
);
const isDuplicate = mcpTools.some((tool, index) => tool.name === name && index !== mcpEditingIndex);
if (isDuplicate) {
showMcpError('工具名称已存在,请使用不同的名称');
return;
}
// 解析模拟返回结果
let mockResponse = null;
if (mockResponseText) {
@@ -320,21 +306,13 @@ function handleMcpSubmit(e) {
return;
}
}
// 构建 inputSchema
const inputSchema = {
type: "object",
properties: {},
required: []
};
const inputSchema = { type: "object", properties: {}, required: [] };
mcpProperties.forEach(prop => {
const propSchema = { type: prop.type };
if (prop.description) {
propSchema.description = prop.description;
}
if ((prop.type === 'integer' || prop.type === 'number')) {
if (prop.minimum !== undefined && prop.minimum !== '') {
propSchema.minimum = prop.minimum;
@@ -343,20 +321,15 @@ function handleMcpSubmit(e) {
propSchema.maximum = prop.maximum;
}
}
inputSchema.properties[prop.name] = propSchema;
if (prop.required) {
inputSchema.required.push(prop.name);
}
});
if (inputSchema.required.length === 0) {
delete inputSchema.required;
}
const tool = { name, description, inputSchema, mockResponse };
if (mcpEditingIndex !== null) {
mcpTools[mcpEditingIndex] = tool;
log(`已更新工具: ${name}`, 'success');
@@ -364,7 +337,6 @@ function handleMcpSubmit(e) {
mcpTools.push(tool);
log(`已添加工具: ${name}`, 'success');
}
saveMcpTools();
renderMcpTools();
closeMcpModal();
@@ -414,11 +386,7 @@ function saveMcpTools() {
* 获取工具列表
*/
export function getMcpTools() {
return mcpTools.map(tool => ({
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema
}));
return mcpTools.map(tool => ({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema }));
}
/**
@@ -426,20 +394,14 @@ export function getMcpTools() {
*/
export function executeMcpTool(toolName, toolArgs) {
const tool = mcpTools.find(t => t.name === toolName);
if (!tool) {
log(`未找到工具: ${toolName}`, 'error');
return {
success: false,
error: `未知工具: ${toolName}`
};
return { success: false, error: `未知工具: ${toolName}` };
}
// 如果有模拟返回结果,使用它
if (tool.mockResponse) {
// 替换模板变量
let responseStr = JSON.stringify(tool.mockResponse);
// 替换 ${paramName} 格式的变量
if (toolArgs) {
Object.keys(toolArgs).forEach(key => {
@@ -447,7 +409,6 @@ export function executeMcpTool(toolName, toolArgs) {
responseStr = responseStr.replace(regex, toolArgs[key]);
});
}
try {
const response = JSON.parse(responseStr);
log(`工具 ${toolName} 执行成功,返回模拟结果: ${responseStr}`, 'success');
@@ -457,21 +418,10 @@ export function executeMcpTool(toolName, toolArgs) {
return tool.mockResponse;
}
}
// 没有模拟返回结果,返回默认成功消息
log(`工具 ${toolName} 执行成功,返回默认结果`, 'success');
return {
success: true,
message: `工具 ${toolName} 执行成功`,
tool: toolName,
arguments: toolArgs
};
return { success: true, message: `工具 ${toolName} 执行成功`, tool: toolName, arguments: toolArgs };
}
// 暴露全局方法供 HTML 内联事件调用
window.mcpModule = {
updateMcpProperty,
deleteMcpProperty,
editMcpTool,
deleteMcpTool
};
window.mcpModule = { updateMcpProperty, deleteMcpProperty, editMcpTool, deleteMcpTool };
@@ -1,5 +1,4 @@
import { otaStatusStyle } from '../../ui/dom-helper.js';
import { log } from '../../utils/logger.js';
import { log } from '../../utils/logger.js?v=0127';
// WebSocket 连接
export async function webSocketConnect(otaUrl, config) {
@@ -62,18 +61,6 @@ function validateConfig(config) {
return true;
}
// 判断wsUrl路径是否存在错误
function validateWsUrl(wsUrl) {
if (wsUrl === '') return false;
// 检查URL格式
if (!wsUrl.startsWith('ws://') && !wsUrl.startsWith('wss://')) {
log('URL格式错误,必须以ws://或wss://开头', 'error');
return false;
}
return true
}
// OTA发送请求,验证状态,并返回响应数据
async function sendOTA(otaUrl, config) {
try {
@@ -96,7 +83,7 @@ async function sendOTA(otaUrl, config) {
},
ota: { label: 'xiaozhi-web-test' },
board: {
type: 'xiaozhi-web-test',
type: config.deviceName,
ssid: 'xiaozhi-web-test',
rssi: 0,
channel: 0,
@@ -115,10 +102,8 @@ async function sendOTA(otaUrl, config) {
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
const result = await res.json();
otaStatusStyle(true)
return result; // 返回完整的响应数据
} catch (err) {
otaStatusStyle(false)
return null; // 失败返回null
}
}
@@ -1,11 +1,11 @@
// WebSocket消息处理模块
import { log } from '../../utils/logger.js';
import { addMessage } from '../../ui/dom-helper.js';
import { webSocketConnect } from './ota-connector.js';
import { getConfig, saveConnectionUrls } from '../../config/manager.js';
import { getAudioPlayer } from '../audio/player.js';
import { getAudioRecorder } from '../audio/recorder.js';
import { getMcpTools, executeMcpTool, setWebSocket as setMcpWebSocket } from '../mcp/tools.js';
import { getConfig, saveConnectionUrls } from '../../config/manager.js?v=0127';
import { uiController } from '../../ui/controller.js?v=0127';
import { log } from '../../utils/logger.js?v=0127';
import { getAudioPlayer } from '../audio/player.js?v=0127';
import { getAudioRecorder } from '../audio/recorder.js?v=0127';
import { executeMcpTool, getMcpTools, setWebSocket as setMcpWebSocket } from '../mcp/tools.js?v=0127';
import { webSocketConnect } from './ota-connector.js?v=0127';
// WebSocket处理器类
export class WebSocketHandler {
@@ -15,6 +15,7 @@ export class WebSocketHandler {
this.onRecordButtonStateChange = null;
this.onSessionStateChange = null;
this.onSessionEmotionChange = null;
this.onChatMessage = null; // 新增:聊天消息回调
this.currentSessionId = null;
this.isRemoteSpeaking = false;
}
@@ -73,36 +74,52 @@ export class WebSocketHandler {
handleTextMessage(message) {
if (message.type === 'hello') {
log(`服务器回应:${JSON.stringify(message, null, 2)}`, 'success');
uiController.startAIChatSession();
} else if (message.type === 'tts') {
this.handleTTSMessage(message);
} else if (message.type === 'audio') {
log(`收到音频控制消息: ${JSON.stringify(message)}`, 'info');
} else if (message.type === 'stt') {
log(`识别结果: ${message.text}`, 'info');
addMessage(`${message.text}`, true);
// 使用新的聊天消息回调显示STT消息
if (this.onChatMessage && message.text) {
this.onChatMessage(message.text, true);
}
} else if (message.type === 'llm') {
log(`大模型回复: ${message.text}`, 'info');
// 使用新的聊天消息回调显示LLM回复
if (this.onChatMessage && message.text) {
this.onChatMessage(message.text, false);
}
// 如果包含表情,更新sessionStatus表情
// 如果包含表情,更新sessionStatus表情并触发Live2D动作
if (message.text && /[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/u.test(message.text)) {
// 提取表情符号
const emojiMatch = message.text.match(/[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/u);
if (emojiMatch && this.onSessionEmotionChange) {
this.onSessionEmotionChange(emojiMatch[0]);
}
// 触发Live2D情绪动作
if (message.emotion) {
console.log(`收到情绪消息: emotion=${message.emotion}, text=${message.text}`);
this.triggerLive2DEmotionAction(message.emotion);
}
}
// 只有当文本不仅仅是表情时,才添加到对话中
// 移除文本中的表情后检查是否还有内容
const textWithoutEmoji = message.text ? message.text.replace(/[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/gu, '').trim() : '';
if (textWithoutEmoji) {
addMessage(message.text);
if (textWithoutEmoji && this.onChatMessage) {
this.onChatMessage(message.text, false);
}
} else if (message.type === 'mcp') {
this.handleMCPMessage(message);
} else {
log(`未知消息类型: ${message.type}`, 'info');
addMessage(JSON.stringify(message, null, 2));
if (this.onChatMessage) {
this.onChatMessage(`未知消息类型: ${message.type}\n${JSON.stringify(message, null, 2)}`, false);
}
}
}
@@ -115,13 +132,26 @@ export class WebSocketHandler {
if (this.onSessionStateChange) {
this.onSessionStateChange(true);
}
// 启动Live2D说话动画
this.startLive2DTalking();
} else if (message.state === 'sentence_start') {
log(`服务器发送语音段: ${message.text}`, 'info');
if (message.text) {
addMessage(message.text);
this.ttsSentenceCount = (this.ttsSentenceCount || 0) + 1;
if (message.text && this.onChatMessage) {
this.onChatMessage(message.text, false);
}
// 确保动画在句子开始时运行
const live2dManager = window.chatApp?.live2dManager;
if (live2dManager && !live2dManager.isTalking) {
this.startLive2DTalking();
}
} else if (message.state === 'sentence_end') {
log(`语音段结束: ${message.text}`, 'info');
// 句子结束时不清除动画,等待下一个句子或最终停止
} else if (message.state === 'stop') {
log('服务器语音传输结束,清空所有音频缓冲', 'info');
@@ -136,6 +166,57 @@ export class WebSocketHandler {
if (this.onSessionStateChange) {
this.onSessionStateChange(false);
}
// 延迟停止Live2D说话动画,确保所有句子都播放完毕
setTimeout(() => {
this.stopLive2DTalking();
this.ttsSentenceCount = 0; // 重置计数器
}, 1000); // 1秒延迟,确保所有句子都完成
}
}
// 启动Live2D说话动画
startLive2DTalking() {
try {
// 获取Live2D管理器实例
const live2dManager = window.chatApp?.live2dManager;
if (live2dManager && live2dManager.live2dModel) {
// 使用音频播放器的分析器节点
live2dManager.startTalking();
log('Live2D说话动画已启动', 'info');
}
} catch (error) {
log(`启动Live2D说话动画失败: ${error.message}`, 'error');
}
}
// 停止Live2D说话动画
stopLive2DTalking() {
try {
const live2dManager = window.chatApp?.live2dManager;
if (live2dManager) {
live2dManager.stopTalking();
log('Live2D说话动画已停止', 'info');
}
} catch (error) {
log(`停止Live2D说话动画失败: ${error.message}`, 'error');
}
}
// 初始化Live2D音频分析器
initializeLive2DAudioAnalyzer() {
try {
const live2dManager = window.chatApp?.live2dManager;
if (live2dManager) {
// 初始化音频分析器(使用音频播放器的上下文)
if (live2dManager.initializeAudioAnalyzer()) {
log('Live2D音频分析器初始化完成,已连接到音频播放器', 'success');
} else {
log('Live2D音频分析器初始化失败,将使用模拟动画', 'warning');
}
}
} catch (error) {
log(`初始化Live2D音频分析器失败: ${error.message}`, 'error');
}
}
@@ -192,6 +273,26 @@ export class WebSocketHandler {
this.websocket.send(replyMessage);
} else if (payload.method === 'initialize') {
log(`收到工具初始化请求: ${JSON.stringify(payload.params)}`, 'info');
const replyMessage = JSON.stringify({
"session_id": message.session_id || "",
"type": "mcp",
"payload": {
"jsonrpc": "2.0",
"id": payload.id,
"result": {
"protocolVersion": "2024-11-05",
"capabilities": {
"tools": {}
},
"serverInfo": {
"name": "xiaozhi-web-test",
"version": "2.1.0"
}
}
}
});
log(`回复初始化响应`, 'info');
this.websocket.send(replyMessage);
} else {
log(`未知的MCP方法: ${payload.method}`, 'warning');
}
@@ -203,7 +304,6 @@ export class WebSocketHandler {
let arrayBuffer;
if (data instanceof ArrayBuffer) {
arrayBuffer = data;
log(`收到ArrayBuffer音频数据,大小: ${data.byteLength}字节`, 'debug');
} else if (data instanceof Blob) {
arrayBuffer = await data.arrayBuffer();
log(`收到Blob音频数据,大小: ${arrayBuffer.byteLength}字节`, 'debug');
@@ -272,6 +372,9 @@ export class WebSocketHandler {
this.onSessionStateChange(false);
}
// 在WebSocket连接成功时初始化Live2D音频分析器
this.initializeLive2DAudioAnalyzer();
await this.sendHelloMessage();
};
@@ -288,7 +391,7 @@ export class WebSocketHandler {
this.websocket.onerror = (error) => {
log(`WebSocket错误: ${error.message || '未知错误'}`, 'error');
uiController.addChatMessage(`⚠️ WebSocket错误: ${error.message || '未知错误'}`, false);
if (this.onConnectionStateChange) {
this.onConnectionStateChange(false);
}
@@ -304,9 +407,8 @@ export class WebSocketHandler {
}
} catch (error) {
log(`WebSocket消息处理错误: ${error.message}`, 'error');
if (typeof event.data === 'string') {
addMessage(event.data);
}
// 不再使用旧的addMessage函数,因为conversationDiv元素不存在
// 错误消息将通过其他方式显示
}
};
}
@@ -340,7 +442,6 @@ export class WebSocketHandler {
const listenMessage = {
type: 'listen',
mode: 'manual',
state: 'detect',
text: text
};
@@ -355,6 +456,24 @@ export class WebSocketHandler {
}
}
/**
* 触发Live2D情绪动作
* @param {string} emotion - 情绪名称
*/
triggerLive2DEmotionAction(emotion) {
try {
const live2dManager = window.chatApp?.live2dManager;
if (live2dManager && typeof live2dManager.triggerEmotionAction === 'function') {
live2dManager.triggerEmotionAction(emotion);
log(`触发Live2D情绪动作: ${emotion}`, 'info');
} else {
log(`无法触发Live2D情绪动作: Live2D管理器未找到或方法不可用`, 'warning');
}
} catch (error) {
log(`触发Live2D情绪动作失败: ${error.message}`, 'error');
}
}
// 获取WebSocket实例
getWebSocket() {
return this.websocket;
File diff suppressed because one or more lines are too long
@@ -0,0 +1,506 @@
/**
* Live2D 管理器
* 负责 Live2D 模型的初始化、嘴部动画控制等功能
*/
class Live2DManager {
constructor() {
this.live2dApp = null;
this.live2dModel = null;
this.isTalking = false;
this.mouthAnimationId = null;
this.mouthParam = 'ParamMouthOpenY';
this.audioContext = null;
this.analyser = null;
this.dataArray = null;
this.lastEmotionActionTime = null; // 上次情绪触发动作的时间
// 情绪到动作的映射
this.emotionToActionMap = {
'happy': 'FlickUp', // 开心-向上轻扫动作
'laughing': 'FlickUp', // 大笑-向上轻扫动作
'funny': 'FlickUp', // 搞笑-向上轻扫动作
'sad': 'FlickDown', // 伤心-向下轻扫动作
'crying': 'FlickDown', // 哭泣-向下轻扫动作
'angry': 'Tap@Body', // 生气-身体点击动作
'surprised': 'Tap', // 惊讶-点击动作
'neutral': 'Flick', // 平常-轻扫动作
'default': 'Flick@Body' // 默认-身体轻扫动作
};
// 单/双击判定配置与状态
this._lastClickTime = 0;
this._lastClickPos = { x: 0, y: 0 };
this._singleClickTimer = null;
this._doubleClickMs = 280; // 双击时间阈值(ms)
this._doubleClickDist = 16; // 双击允许的最大位移(px)
// 滑动判定
this._pointerDown = false;
this._downPos = { x: 0, y: 0 };
this._downTime = 0;
this._downArea = 'Body';
this._movedBeyondClick = false;
this._swipeMinDist = 24; // 触发滑动的最小距离
}
/**
* 初始化 Live2D
*/
async initializeLive2D() {
try {
const canvas = document.getElementById('live2d-stage');
// 供内部使用
window.PIXI = PIXI;
this.live2dApp = new PIXI.Application({
view: canvas,
height: window.innerHeight,
width: window.innerWidth,
resolution: window.devicePixelRatio,
autoDensity: true,
antialias: true,
backgroundAlpha: 0,
});
// 加载 Live2D 模型 - 动态检测当前目录,适配不同环境
// 获取当前HTML文件所在的目录路径
const currentPath = window.location.pathname;
const lastSlashIndex = currentPath.lastIndexOf('/');
const basePath = currentPath.substring(0, lastSlashIndex + 1);
const modelPath = basePath + 'hiyori_pro_zh/runtime/hiyori_pro_t11.model3.json';
this.live2dModel = await PIXI.live2d.Live2DModel.from(modelPath);
this.live2dApp.stage.addChild(this.live2dModel);
// 设置模型属性
this.live2dModel.scale.set(0.33);
this.live2dModel.x = (window.innerWidth - this.live2dModel.width) * 0.5;
this.live2dModel.y = -50;
// 启用交互并监听点击命中(头部/身体等)
this.live2dModel.interactive = true;
this.live2dModel.on('doublehit', (args) => {
const area = Array.isArray(args) ? args[0] : args;
// 触发双击动作
if (area === 'Body') {
this.motion('Flick@Body');
} else if (area === 'Head' || area === 'Face') {
this.motion('Flick');
}
const app = window.chatApp;
const payload = JSON.stringify({ type: 'live2d', event: 'doublehit', area });
if (app && app.dataChannel && app.dataChannel.readyState === 'open') {
app.dataChannel.send(payload);
}
});
this.live2dModel.on('singlehit', (args) => {
const area = Array.isArray(args) ? args[0] : args;
// 触发单击动作
if (area === 'Body') {
this.motion('Tap@Body');
} else if (area === 'Head' || area === 'Face') {
this.motion('Tap');
}
const app = window.chatApp;
const payload = JSON.stringify({ type: 'live2d', event: 'singlehit', area });
if (app && app.dataChannel && app.dataChannel.readyState === 'open') {
app.dataChannel.send(payload);
}
});
this.live2dModel.on('swipe', (args) => {
const area = Array.isArray(args) ? args[0] : args;
const dir = Array.isArray(args) ? args[1] : undefined;
// 触发滑动动作
if (area === 'Body') {
if (dir === 'up') {
this.motion('FlickUp');
} else if (dir === 'down') {
this.motion('FlickDown');
}
} else if (area === 'Head' || area === 'Face') {
if (dir === 'up') {
this.motion('FlickUp');
} else if (dir === 'down') {
this.motion('FlickDown');
}
}
const app = window.chatApp;
const payload = JSON.stringify({ type: 'live2d', event: 'swipe', area, dir });
if (app && app.dataChannel && app.dataChannel.readyState === 'open') {
app.dataChannel.send(payload);
}
});
// 兜底:自定义"头部/身体"命中区域 + 单/双击/滑动区分
this.live2dModel.on('pointerdown', (event) => {
try {
const global = event.data.global;
const bounds = this.live2dModel.getBounds();
// 仅在点击落在模型可见范围内时判定
if (!bounds || !bounds.contains(global.x, global.y)) return;
const relX = (global.x - bounds.x) / (bounds.width || 1);
const relY = (global.y - bounds.y) / (bounds.height || 1);
let area = '';
// 经验阈值:模型可见矩形的上部 20% 视为"头部"区域
if (relX >= 0.4 && relX <= 0.6) {
if (relY <= 0.15) {
area = 'Head';
} else if (relY <= 0.23) {
area = 'Face';
} else {
area = 'Body';
}
}
if (area === '') {
return;
}
// 记录按下状态用于滑动判定
this._pointerDown = true;
this._downPos = { x: global.x, y: global.y };
this._downTime = performance.now();
this._downArea = area;
this._movedBeyondClick = false;
const now = performance.now();
const dt = now - (this._lastClickTime || 0);
const dx = global.x - (this._lastClickPos?.x || 0);
const dy = global.y - (this._lastClickPos?.y || 0);
const dist = Math.hypot(dx, dy);
// 命中确认:仅当点击在模型上时做单/双击判断
if (this._lastClickTime && dt <= this._doubleClickMs && dist <= this._doubleClickDist) {
// 判定为双击:取消待触发的单击事件
if (this._singleClickTimer) {
clearTimeout(this._singleClickTimer);
this._singleClickTimer = null;
}
if (typeof this.live2dModel.emit === 'function') {
this.live2dModel.emit('doublehit', [area]);
}
this._lastClickTime = 0;
this._pointerDown = false; // 双击完成,重置状态
return;
}
// 可能是单击:记录并延迟确认
this._lastClickTime = now;
this._lastClickPos = { x: global.x, y: global.y };
if (this._singleClickTimer) {
clearTimeout(this._singleClickTimer);
this._singleClickTimer = null;
}
this._singleClickTimer = setTimeout(() => {
// 若在等待期间发生了移动超过阈值,则不再当作单击
if (!this._movedBeyondClick && typeof this.live2dModel.emit === 'function') {
this.live2dModel.emit('singlehit', [area]);
}
this._singleClickTimer = null;
this._lastClickTime = 0;
}, this._doubleClickMs);
} catch (e) {
// 忽略自定义命中判断中的异常,避免影响主流程
}
});
// 指针移动:用于判定是否从"点击"升级为"滑动"
this.live2dModel.on('pointermove', (event) => {
try {
if (!this._pointerDown) return;
const global = event.data.global;
const dx = global.x - this._downPos.x;
const dy = global.y - this._downPos.y;
const dist = Math.hypot(dx, dy);
// 使用 _doubleClickDist 作为点击/滑动的判定阈值
if (dist > this._doubleClickDist) {
this._movedBeyondClick = true;
// 若已超出点击阈值,取消可能的单击触发
if (this._singleClickTimer) {
clearTimeout(this._singleClickTimer);
this._singleClickTimer = null;
}
this._lastClickTime = 0;
}
} catch (e) {
// 忽略移动判定中的异常
}
});
// 指针抬起:确认是否为滑动
const handlePointerUp = (event) => {
try {
if (!this._pointerDown) return;
const global = (event && event.data && event.data.global) ? event.data.global : { x: this._downPos.x, y: this._downPos.y };
const dx = global.x - this._downPos.x;
const dy = global.y - this._downPos.y;
const dist = Math.hypot(dx, dy);
// 滑动:超过滑动最小距离则触发 swipe 事件(携带方向与区域)
if (this._movedBeyondClick && dist >= this._swipeMinDist) {
if (typeof this.live2dModel.emit === 'function') {
const dir = Math.abs(dx) >= Math.abs(dy)
? (dx > 0 ? 'right' : 'left')
: (dy > 0 ? 'down' : 'up');
this.live2dModel.emit('swipe', [this._downArea, dir]);
}
// 终止:不再让单击/双击触发
if (this._singleClickTimer) {
clearTimeout(this._singleClickTimer);
this._singleClickTimer = null;
}
this._lastClickTime = 0;
}
} catch (e) {
// 忽略抬起判定中的异常
}
finally {
this._pointerDown = false;
this._movedBeyondClick = false;
}
};
this.live2dModel.on('pointerup', handlePointerUp);
this.live2dModel.on('pointerupoutside', handlePointerUp);
// 添加窗口大小变化监听器,保持模型在Canvas中间和底部
window.addEventListener('resize', () => {
if (this.live2dModel) {
// 使用窗口实际尺寸重新计算模型位置
this.live2dModel.x = (window.innerWidth - this.live2dModel.width) * 0.5;
this.live2dModel.y = -50;
}
});
} catch (err) {
console.error('加载 Live2D 模型失败:', err);
}
}
/**
* 初始化音频分析器 - 使用音频播放器的分析器节点
*/
initializeAudioAnalyzer() {
try {
// 获取音频播放器实例
const audioPlayer = window.chatApp?.audioPlayer;
if (!audioPlayer) {
console.warn('音频播放器未初始化,无法获取分析器节点');
return false;
}
// 获取音频播放器的音频上下文
this.audioContext = audioPlayer.getAudioContext();
if (!this.audioContext) {
console.warn('无法获取音频播放器的音频上下文');
return false;
}
// 创建分析器节点
this.analyser = this.audioContext.createAnalyser();
this.analyser.fftSize = 256;
this.dataArray = new Uint8Array(this.analyser.frequencyBinCount);
return true;
} catch (error) {
console.error('初始化音频分析器失败:', error);
return false;
}
}
/**
* 连接到音频播放器的输出节点
*/
connectToAudioPlayer() {
try {
// 获取音频播放器的流上下文
const audioPlayer = window.chatApp?.audioPlayer;
if (!audioPlayer || !audioPlayer.streamingContext) {
console.warn('音频播放器或流上下文未初始化');
return false;
}
// 获取音频播放器的流上下文
const streamingContext = audioPlayer.streamingContext;
// 获取分析器节点
const analyser = streamingContext.getAnalyser();
if (!analyser) {
console.warn('音频播放器尚未创建分析器节点,无法连接');
return false;
}
// 使用音频播放器的分析器节点
this.analyser = analyser;
this.dataArray = new Uint8Array(this.analyser.frequencyBinCount);
return true;
} catch (error) {
console.error('连接到音频播放器失败:', error);
return false;
}
}
/**
* 嘴部动画循环
*/
animateMouth() {
if (!this.isTalking) return;
if (!this.live2dModel) return;
const internal = this.live2dModel && this.live2dModel.internalModel;
if (internal && internal.coreModel) {
const coreModel = internal.coreModel;
// 获取音频分贝值
let mouthValue = 0;
let average = 0;
if (this.analyser && this.dataArray) {
this.analyser.getByteFrequencyData(this.dataArray);
average = this.dataArray.reduce((a, b) => a + b) / this.dataArray.length;
// 优化音量映射函数,使中等音量范围变化更明显
// 使用S形曲线函数,在中等音量范围有更好的响应
const normalizedVolume = average / 255;
// S形曲线:在0.3-0.7范围内有最大的斜率(变化最明显)
if (normalizedVolume < 0.3) {
// 低音量:缓慢增长
mouthValue = Math.pow(normalizedVolume / 0.3, 1.5) * 0.3;
} else if (normalizedVolume < 0.7) {
// 中等音量:线性增长,变化最明显
mouthValue = 0.3 + (normalizedVolume - 0.3) / 0.4 * 0.5;
} else {
// 高音量:缓慢接近最大值
mouthValue = 0.8 + Math.pow((normalizedVolume - 0.7) / 0.3, 1.2) * 0.2;
}
// 确保嘴部参数在0-1范围内
mouthValue = Math.min(Math.max(mouthValue, 0), 1);
}
coreModel.setParameterValueById(this.mouthParam, mouthValue);
coreModel.update();
}
this.mouthAnimationId = requestAnimationFrame(() => this.animateMouth());
}
/**
* 开始说话动画
*/
startTalking() {
if (this.isTalking || !this.live2dModel) return;
// 确保音频分析器已初始化
if (!this.analyser) {
if (!this.initializeAudioAnalyzer()) {
console.warn('音频分析器初始化失败,将使用模拟动画');
// 即使分析器初始化失败,也启动动画(使用模拟数据)
this.isTalking = true;
this.animateMouth();
return;
}
}
// 连接到音频播放器输出
if (!this.connectToAudioPlayer()) {
console.warn('无法连接到音频播放器输出,将使用模拟动画');
}
this.isTalking = true;
this.animateMouth();
}
/**
* 停止说话动画
*/
stopTalking() {
this.isTalking = false;
if (this.mouthAnimationId) {
cancelAnimationFrame(this.mouthAnimationId);
this.mouthAnimationId = null;
}
// 重置嘴部参数
if (this.live2dModel) {
const internal = this.live2dModel.internalModel;
if (internal && internal.coreModel) {
const coreModel = internal.coreModel;
coreModel.setParameterValueById(this.mouthParam, 0);
coreModel.update();
}
}
}
/**
* 基于情绪触发动作
* @param {string} emotion - 情绪名称
*/
triggerEmotionAction(emotion) {
if (!this.live2dModel) return;
// 添加冷却时间控制,避免过于频繁触发
const now = Date.now();
if (this.lastEmotionActionTime && now - this.lastEmotionActionTime < 5000) { // 5秒冷却时间
return;
}
// 根据情绪获取对应的动作
const action = this.emotionToActionMap[emotion] || this.emotionToActionMap['default'];
// 触发动作并记录时间
this.motion(action);
this.lastEmotionActionTime = now;
}
/**
* 触发模型动作(Motion)
* @param {string} name - 动作分组名称,如 'TapBody'、'FlickUp'、'Idle' 等
*/
motion(name) {
try {
if (!this.live2dModel) return;
this.live2dModel.motion(name);
} catch (error) {
console.error('触发动作失败:', error);
}
}
/**
* 清理资源
*/
destroy() {
this.stopTalking();
// 清理音频分析器
if (this.audioContext) {
this.audioContext.close();
this.audioContext = null;
}
this.analyser = null;
this.dataArray = null;
// 清理 Live2D 应用
if (this.live2dApp) {
this.live2dApp.destroy(true);
this.live2dApp = null;
}
this.live2dModel = null;
}
}
// 导出全局实例
window.Live2DManager = Live2DManager;
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,27 @@
// 背景图加载检测
(function() {
const backgroundContainer = document.getElementById('backgroundContainer');
// 提取背景图片URL
let bgImageUrl = window.getComputedStyle(backgroundContainer).backgroundImage;
const urlMatch = bgImageUrl && bgImageUrl.match(/url\(["']?(.*?)["']?\)/);
if (!urlMatch || !urlMatch[1]) {
console.warn('未提取到有效的背景图片URL');
return;
}
bgImageUrl = urlMatch[1];
const bgImage = new Image();
bgImage.onerror = function() {
console.error('背景图片加载失败:', bgImageUrl);
};
// 加载成功显示模型加载
bgImage.onload = function() {
modelLoading.style.display = 'flex';
};
bgImage.src = bgImageUrl;
})();
+537 -278
View File
@@ -1,182 +1,553 @@
// UI控制模块
import { loadConfig, saveConfig } from '../config/manager.js';
import { getAudioPlayer } from '../core/audio/player.js';
import { getAudioRecorder } from '../core/audio/recorder.js';
import { getWebSocketHandler } from '../core/network/websocket.js';
// UI controller module
import { loadConfig, saveConfig } from '../config/manager.js?v=0127';
import { getAudioPlayer } from '../core/audio/player.js?v=0127';
import { getAudioRecorder } from '../core/audio/recorder.js?v=0127';
import { getWebSocketHandler } from '../core/network/websocket.js?v=0127';
// UI控制器类
export class UIController {
// UI controller class
class UIController {
constructor() {
this.isEditing = false;
this.visualizerCanvas = null;
this.visualizerContext = null;
this.audioStatsTimer = null;
this.currentBackgroundIndex = 0;
this.backgroundImages = ['1.png', '2.png', '3.png'];
// Bind methods
this.init = this.init.bind(this);
this.initEventListeners = this.initEventListeners.bind(this);
this.updateDialButton = this.updateDialButton.bind(this);
this.addChatMessage = this.addChatMessage.bind(this);
this.switchBackground = this.switchBackground.bind(this);
this.showModal = this.showModal.bind(this);
this.hideModal = this.hideModal.bind(this);
this.switchTab = this.switchTab.bind(this);
}
// 初始化
// Initialize
init() {
console.log('UIController init started');
this.visualizerCanvas = document.getElementById('audioVisualizer');
this.visualizerContext = this.visualizerCanvas.getContext('2d');
if (this.visualizerCanvas) {
this.visualizerContext = this.visualizerCanvas.getContext('2d');
this.initVisualizer();
}
// Check if connect button exists during initialization
const connectBtn = document.getElementById('connectBtn');
console.log('connectBtn during init:', connectBtn);
this.initVisualizer();
this.initEventListeners();
this.startAudioStatsMonitor();
loadConfig();
// Register recording callback
const audioRecorder = getAudioRecorder();
audioRecorder.onRecordingStart = (seconds) => {
this.updateRecordButtonState(true, seconds);
};
// Initialize status display
this.updateConnectionUI(false);
this.updateDialButton(false);
console.log('UIController init completed');
}
// 初始化可视化器
// Initialize visualizer
initVisualizer() {
this.visualizerCanvas.width = this.visualizerCanvas.clientWidth;
this.visualizerCanvas.height = this.visualizerCanvas.clientHeight;
this.visualizerContext.fillStyle = '#fafafa';
this.visualizerContext.fillRect(0, 0, this.visualizerCanvas.width, this.visualizerCanvas.height);
}
// 更新状态显示
updateStatusDisplay(element, text) {
element.textContent = text;
element.removeAttribute('style');
element.classList.remove('connected');
if (text.includes('已连接')) {
element.classList.add('connected');
if (this.visualizerCanvas) {
this.visualizerCanvas.width = this.visualizerCanvas.clientWidth;
this.visualizerCanvas.height = this.visualizerCanvas.clientHeight;
this.visualizerContext.fillStyle = '#fafafa';
this.visualizerContext.fillRect(0, 0, this.visualizerCanvas.width, this.visualizerCanvas.height);
}
console.log('更新状态:', text, '类列表:', element.className, '样式属性:', element.getAttribute('style'));
}
// 更新连接状态UI
// Initialize event listeners
initEventListeners() {
// Settings button
const settingsBtn = document.getElementById('settingsBtn');
if (settingsBtn) {
settingsBtn.addEventListener('click', () => {
this.showModal('settingsModal');
});
}
// Background switch button
const backgroundBtn = document.getElementById('backgroundBtn');
if (backgroundBtn) {
backgroundBtn.addEventListener('click', this.switchBackground);
}
// Dial button
const dialBtn = document.getElementById('dialBtn');
if (dialBtn) {
dialBtn.addEventListener('click', () => {
const wsHandler = getWebSocketHandler();
const isConnected = wsHandler.isConnected();
if (isConnected) {
wsHandler.disconnect();
this.updateDialButton(false);
this.addChatMessage('Disconnected, see you next time~😊', false);
} else {
// Check if OTA URL is filled
const otaUrlInput = document.getElementById('otaUrl');
if (!otaUrlInput || !otaUrlInput.value.trim()) {
// If OTA URL is not filled, show settings modal and switch to device tab
this.showModal('settingsModal');
this.switchTab('device');
this.addChatMessage('Please fill in OTA server URL', false);
return;
}
// Start connection process
this.handleConnect();
}
});
}
// Record button
const recordBtn = document.getElementById('recordBtn');
if (recordBtn) {
recordBtn.addEventListener('click', () => {
const audioRecorder = getAudioRecorder();
if (audioRecorder.isRecording) {
audioRecorder.stop();
// Restore record button to normal state
recordBtn.classList.remove('recording');
recordBtn.querySelector('.btn-text').textContent = '录音';
} else {
// Update button state to recording
recordBtn.classList.add('recording');
recordBtn.querySelector('.btn-text').textContent = '录音中';
// Start recording, update button state after delay
setTimeout(() => {
audioRecorder.start();
}, 100);
}
});
}
// Chat input event listener
const chatIpt = document.getElementById('chatIpt');
if (chatIpt) {
const wsHandler = getWebSocketHandler();
chatIpt.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
if (e.target.value) {
wsHandler.sendTextMessage(e.target.value);
e.target.value = '';
return;
}
}
});
}
// Close button
const closeButtons = document.querySelectorAll('.close-btn');
closeButtons.forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const modal = e.target.closest('.modal');
if (modal) {
if (modal.id === 'settingsModal') {
saveConfig();
}
this.hideModal(modal.id);
}
});
});
// Settings tab switch
const tabBtns = document.querySelectorAll('.tab-btn');
tabBtns.forEach(btn => {
btn.addEventListener('click', (e) => {
this.switchTab(e.target.dataset.tab);
});
});
// Click modal background to close
const modals = document.querySelectorAll('.modal');
modals.forEach(modal => {
modal.addEventListener('click', (e) => {
if (e.target === modal) {
if (modal.id === 'settingsModal') {
saveConfig();
}
this.hideModal(modal.id);
}
});
});
// Add MCP tool button
const addMCPToolBtn = document.getElementById('addMCPToolBtn');
if (addMCPToolBtn) {
addMCPToolBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.addMCPTool();
});
}
// Connect button and send button are not removed, can be added to dial button later
}
// Update connection status UI
updateConnectionUI(isConnected) {
const connectionStatus = document.getElementById('connectionStatus');
const otaStatus = document.getElementById('otaStatus');
const connectButton = document.getElementById('connectButton');
const messageInput = document.getElementById('messageInput');
const sendTextButton = document.getElementById('sendTextButton');
const recordButton = document.getElementById('recordButton');
const statusDot = document.querySelector('.status-dot');
if (isConnected) {
this.updateStatusDisplay(connectionStatus, '● WS已连接');
this.updateStatusDisplay(otaStatus, '● OTA已连接');
connectButton.textContent = '断开';
messageInput.disabled = false;
sendTextButton.disabled = false;
recordButton.disabled = false;
} else {
this.updateStatusDisplay(connectionStatus, '● WS未连接');
this.updateStatusDisplay(otaStatus, '● OTA未连接');
connectButton.textContent = '连接';
messageInput.disabled = true;
sendTextButton.disabled = true;
recordButton.disabled = true;
// 断开连接时,会话状态变为离线
this.updateSessionStatus(null);
}
}
// 更新录音按钮状态
updateRecordButtonState(isRecording, seconds = 0) {
const recordButton = document.getElementById('recordButton');
if (isRecording) {
recordButton.textContent = `停止录音 ${seconds.toFixed(1)}`;
recordButton.classList.add('recording');
} else {
recordButton.textContent = '开始录音';
recordButton.classList.remove('recording');
}
recordButton.disabled = false;
}
// 更新会话状态UI
updateSessionStatus(isSpeaking) {
const sessionStatus = document.getElementById('sessionStatus');
if (!sessionStatus) return;
// 保留背景元素
const bgHtml = '<span id="sessionStatusBg" style="position: absolute; left: 0; top: 0; bottom: 0; width: 0%; background: linear-gradient(90deg, rgba(76, 175, 80, 0.2), rgba(33, 150, 243, 0.2)); transition: width 0.15s ease-out, background 0.3s ease; z-index: 0; border-radius: 20px;"></span>';
if (isSpeaking === null) {
// 离线状态
sessionStatus.innerHTML = bgHtml + '<span style="position: relative; z-index: 1;"><span class="emoji-large">😶</span> 小智离线中</span>';
sessionStatus.className = 'status offline';
} else if (isSpeaking) {
// 说话中
sessionStatus.innerHTML = bgHtml + '<span style="position: relative; z-index: 1;"><span class="emoji-large">😶</span> 小智说话中</span>';
sessionStatus.className = 'status speaking';
} else {
// 聆听中
sessionStatus.innerHTML = bgHtml + '<span style="position: relative; z-index: 1;"><span class="emoji-large">😶</span> 小智聆听中</span>';
sessionStatus.className = 'status listening';
}
}
// 更新会话表情
updateSessionEmotion(emoji) {
const sessionStatus = document.getElementById('sessionStatus');
if (!sessionStatus) return;
// 获取当前文本内容,提取非表情部分
let currentText = sessionStatus.textContent;
// 移除现有的表情符号
currentText = currentText.replace(/[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/gu, '').trim();
// 保留背景元素
const bgHtml = '<span id="sessionStatusBg" style="position: absolute; left: 0; top: 0; bottom: 0; width: 0%; background: linear-gradient(90deg, rgba(76, 175, 80, 0.2), rgba(33, 150, 243, 0.2)); transition: width 0.15s ease-out, background 0.3s ease; z-index: 0; border-radius: 20px;"></span>';
// 使用 innerHTML 添加带样式的表情
sessionStatus.innerHTML = bgHtml + `<span style="position: relative; z-index: 1;"><span class="emoji-large">${emoji}</span> ${currentText}</span>`;
}
// 更新音频统计信息
updateAudioStats() {
const audioPlayer = getAudioPlayer();
const stats = audioPlayer.getAudioStats();
const sessionStatus = document.getElementById('sessionStatus');
const sessionStatusBg = document.getElementById('sessionStatusBg');
// 只在说话状态下显示背景进度
if (sessionStatus && sessionStatus.classList.contains('speaking') && sessionStatusBg) {
if (stats.pendingPlay > 0) {
// 计算进度:5包=50%10包及以上=100%
let percentage;
if (stats.pendingPlay >= 10) {
percentage = 100;
} else {
percentage = (stats.pendingPlay / 10) * 100;
}
sessionStatusBg.style.width = `${percentage}%`;
// 根据缓冲量改变背景颜色
if (stats.pendingPlay < 5) {
// 缓冲不足:橙红色半透明
sessionStatusBg.style.background = 'linear-gradient(90deg, rgba(255, 152, 0, 0.25), rgba(255, 87, 34, 0.25))';
} else if (stats.pendingPlay < 10) {
// 一般:黄绿色半透明
sessionStatusBg.style.background = 'linear-gradient(90deg, rgba(205, 220, 57, 0.25), rgba(76, 175, 80, 0.25))';
} else {
// 充足:绿蓝色半透明
sessionStatusBg.style.background = 'linear-gradient(90deg, rgba(76, 175, 80, 0.25), rgba(33, 150, 243, 0.25))';
if (connectionStatus) {
if (isConnected) {
connectionStatus.textContent = '已连接';
if (statusDot) {
statusDot.className = 'status-dot status-connected';
}
} else {
// 没有缓冲,隐藏背景
sessionStatusBg.style.width = '0%';
}
} else {
// 非说话状态,隐藏背景
if (sessionStatusBg) {
sessionStatusBg.style.width = '0%';
connectionStatus.textContent = '离线';
if (statusDot) {
statusDot.className = 'status-dot status-disconnected';
}
}
}
}
// 启动音频统计监控
// Update dial button state
updateDialButton(isConnected) {
const dialBtn = document.getElementById('dialBtn');
const recordBtn = document.getElementById('recordBtn');
if (dialBtn) {
if (isConnected) {
dialBtn.classList.add('dial-active');
dialBtn.querySelector('.btn-text').textContent = '挂断';
// Update dial button icon to hang up icon
dialBtn.querySelector('svg').innerHTML = `
<path d="M12,9C10.4,9 9,10.4 9,12C9,13.6 10.4,15 12,15C13.6,15 15,13.6 15,12C15,10.4 13.6,9 12,9M12,17C9.2,17 7,14.8 7,12C7,9.2 9.2,7 12,7C14.8,7 17,9.2 17,12C17,14.8 14.8,17 12,17M12,4.5C7,4.5 2.7,7.6 1,12C2.7,16.4 7,19.5 12,19.5C17,19.5 21.3,16.4 23,12C21.3,7.6 17,4.5 12,4.5Z"/>
`;
} else {
dialBtn.classList.remove('dial-active');
dialBtn.querySelector('.btn-text').textContent = '拨号';
// Restore dial button icon
dialBtn.querySelector('svg').innerHTML = `
<path d="M6.62,10.79C8.06,13.62 10.38,15.94 13.21,17.38L15.41,15.18C15.69,14.9 16.08,14.82 16.43,14.93C17.55,15.3 18.75,15.5 20,15.5A1,1 0 0,1 21,16.5V20A1,1 0 0,1 20,21A17,17 0 0,1 3,4A1,1 0 0,1 4,3H7.5A1,1 0 0,1 8.5,4C8.5,5.25 8.7,6.45 9.07,7.57C9.18,7.92 9.1,8.31 8.82,8.59L6.62,10.79Z"/>
`;
}
}
// Update record button state
if (recordBtn) {
const microphoneAvailable = window.microphoneAvailable !== false;
if (isConnected && microphoneAvailable) {
recordBtn.disabled = false;
recordBtn.title = '开始录音';
// Restore record button to normal state
recordBtn.querySelector('.btn-text').textContent = '录音';
recordBtn.classList.remove('recording');
} else {
recordBtn.disabled = true;
if (!microphoneAvailable) {
recordBtn.title = window.isHttpNonLocalhost ? '当前由于是http访问,无法录音,只能用文字交互' : '麦克风不可用';
} else {
recordBtn.title = '请先连接服务器';
}
// Restore record button to normal state
recordBtn.querySelector('.btn-text').textContent = '录音';
recordBtn.classList.remove('recording');
}
}
}
// Update record button state
updateRecordButtonState(isRecording, seconds = 0) {
const recordBtn = document.getElementById('recordBtn');
if (recordBtn) {
if (isRecording) {
recordBtn.querySelector('.btn-text').textContent = `录音中`;
recordBtn.classList.add('recording');
} else {
recordBtn.querySelector('.btn-text').textContent = '录音';
recordBtn.classList.remove('recording');
}
// Only enable button when microphone is available
recordBtn.disabled = window.microphoneAvailable === false;
}
}
/**
* Update microphone availability state
* @param {boolean} isAvailable - Whether microphone is available
* @param {boolean} isHttpNonLocalhost - Whether it is HTTP non-localhost access
*/
updateMicrophoneAvailability(isAvailable, isHttpNonLocalhost) {
const recordBtn = document.getElementById('recordBtn');
if (!recordBtn) return;
if (!isAvailable) {
// Disable record button
recordBtn.disabled = true;
// Update button text and title
recordBtn.querySelector('.btn-text').textContent = '录音';
recordBtn.title = isHttpNonLocalhost ? '当前由于是http访问,无法录音,只能用文字交互' : '麦克风不可用';
} else {
// If connected, enable record button
const wsHandler = getWebSocketHandler();
if (wsHandler && wsHandler.isConnected()) {
recordBtn.disabled = false;
recordBtn.title = '开始录音';
}
}
}
// Add chat message
addChatMessage(content, isUser = false) {
const chatStream = document.getElementById('chatStream');
if (!chatStream) return;
const messageDiv = document.createElement('div');
messageDiv.className = `chat-message ${isUser ? 'user' : 'ai'}`;
messageDiv.innerHTML = `<div class="message-bubble">${content}</div>`;
chatStream.appendChild(messageDiv);
// Scroll to bottom
chatStream.scrollTop = chatStream.scrollHeight;
}
// Switch background
switchBackground() {
this.currentBackgroundIndex = (this.currentBackgroundIndex + 1) % this.backgroundImages.length;
const backgroundContainer = document.querySelector('.background-container');
if (backgroundContainer) {
backgroundContainer.style.backgroundImage = `url('./images/${this.backgroundImages[this.currentBackgroundIndex]}')`;
}
}
// Show modal
showModal(modalId) {
const modal = document.getElementById(modalId);
if (modal) {
modal.style.display = 'flex';
}
}
// Hide modal
hideModal(modalId) {
const modal = document.getElementById(modalId);
if (modal) {
modal.style.display = 'none';
}
}
// Switch tab
switchTab(tabName) {
// Remove active class from all tabs
const tabBtns = document.querySelectorAll('.tab-btn');
const tabContents = document.querySelectorAll('.tab-content');
tabBtns.forEach(btn => btn.classList.remove('active'));
tabContents.forEach(content => content.classList.remove('active'));
// Activate selected tab
const activeTabBtn = document.querySelector(`[data-tab="${tabName}"]`);
const activeTabContent = document.getElementById(`${tabName}Tab`);
if (activeTabBtn && activeTabContent) {
activeTabBtn.classList.add('active');
activeTabContent.classList.add('active');
}
}
// Start AI chat session after connection
startAIChatSession() {
this.addChatMessage('连接成功,开始聊天吧~😊', false);
// Check microphone availability and show error messages if needed
if (!window.microphoneAvailable) {
if (window.isHttpNonLocalhost) {
this.addChatMessage('⚠️ 当前由于是http访问,无法录音,只能用文字交互', false);
} else {
this.addChatMessage('⚠️ 麦克风不可用,请检查权限设置,只能用文字交互', false);
}
}
// Start recording only if microphone is available
if (window.microphoneAvailable) {
const recordBtn = document.getElementById('recordBtn');
if (recordBtn) {
recordBtn.click();
}
}
}
// Handle connect button click
async handleConnect() {
console.log('handleConnect called');
// Switch to device settings tab
this.switchTab('device');
// Wait for DOM update
await new Promise(resolve => setTimeout(resolve, 50));
const otaUrlInput = document.getElementById('otaUrl');
console.log('otaUrl element:', otaUrlInput);
if (!otaUrlInput || !otaUrlInput.value) {
this.addChatMessage('请输入OTA服务器地址', false);
return;
}
const otaUrl = otaUrlInput.value;
console.log('otaUrl value:', otaUrl);
// Update dial button state to connecting
const dialBtn = document.getElementById('dialBtn');
if (dialBtn) {
dialBtn.classList.add('dial-active');
dialBtn.querySelector('.btn-text').textContent = '连接中...';
dialBtn.disabled = true;
}
// Show connecting message
this.addChatMessage('正在连接服务器...', false);
const chatIpt = document.getElementById('chatIpt');
if (chatIpt) {
chatIpt.style.display = 'flex';
}
try {
// Get WebSocket handler instance
const wsHandler = getWebSocketHandler();
// Register connection state callback BEFORE connecting
wsHandler.onConnectionStateChange = (isConnected) => {
this.updateConnectionUI(isConnected);
this.updateDialButton(isConnected);
};
// Register chat message callback BEFORE connecting
wsHandler.onChatMessage = (text, isUser) => {
this.addChatMessage(text, isUser);
};
// Register record button state callback BEFORE connecting
wsHandler.onRecordButtonStateChange = (isRecording) => {
const recordBtn = document.getElementById('recordBtn');
if (recordBtn) {
if (isRecording) {
recordBtn.classList.add('recording');
recordBtn.querySelector('.btn-text').textContent = '录音中';
} else {
recordBtn.classList.remove('recording');
recordBtn.querySelector('.btn-text').textContent = '录音';
}
}
};
const isConnected = await wsHandler.connect();
if (isConnected) {
// Check microphone availability (check again after connection)
const { checkMicrophoneAvailability } = await import('../core/audio/recorder.js?v=0127');
const micAvailable = await checkMicrophoneAvailability();
if (!micAvailable) {
const isHttp = window.isHttpNonLocalhost;
if (isHttp) {
this.addChatMessage('⚠️ 当前由于是http访问,无法录音,只能用文字交互', false);
}
// Update global state
window.microphoneAvailable = false;
}
// Update dial button state
const dialBtn = document.getElementById('dialBtn');
if (dialBtn) {
dialBtn.disabled = false;
dialBtn.querySelector('.btn-text').textContent = '挂断';
dialBtn.classList.add('dial-active');
}
this.hideModal('settingsModal');
} else {
throw new Error('OTA连接失败');
}
} catch (error) {
console.error('Connection error details:', {
message: error.message,
stack: error.stack,
name: error.name
});
// Show error message
const errorMessage = error.message.includes('Cannot set properties of null')
? '连接失败:请检查设备连接'
: `连接失败: ${error.message}`;
this.addChatMessage(errorMessage, false);
// Restore dial button state
const dialBtn = document.getElementById('dialBtn');
if (dialBtn) {
dialBtn.disabled = false;
dialBtn.querySelector('.btn-text').textContent = '拨号';
dialBtn.classList.remove('dial-active');
console.log('Dial button state restored successfully');
}
}
}
// Add MCP tool
addMCPTool() {
const mcpToolsList = document.getElementById('mcpToolsList');
if (!mcpToolsList) return;
const toolId = `mcp-tool-${Date.now()}`;
const toolDiv = document.createElement('div');
toolDiv.className = 'properties-container';
toolDiv.innerHTML = `
<div class="property-item">
<input type="text" placeholder="工具名称" value="新工具">
<input type="text" placeholder="工具描述" value="工具描述">
<button class="remove-property" onclick="uiController.removeMCPTool('${toolId}')">删除</button>
</div>
`;
mcpToolsList.appendChild(toolDiv);
}
// Remove MCP tool
removeMCPTool(toolId) {
const toolElement = document.getElementById(toolId);
if (toolElement) {
toolElement.remove();
}
}
// Update audio statistics display
updateAudioStats() {
const audioPlayer = getAudioPlayer();
if (!audioPlayer) return;
const stats = audioPlayer.getAudioStats();
// Here can add audio statistics UI update logic
}
// Start audio statistics monitor
startAudioStatsMonitor() {
// 每100ms更新一次音频统计
// Update audio statistics every 100ms
this.audioStatsTimer = setInterval(() => {
this.updateAudioStats();
}, 100);
}
// 停止音频统计监控
// Stop audio statistics monitor
stopAudioStatsMonitor() {
if (this.audioStatsTimer) {
clearInterval(this.audioStatsTimer);
@@ -184,8 +555,10 @@ export class UIController {
}
}
// 绘制音频可视化效果
// Draw audio visualizer waveform
drawVisualizer(dataArray) {
if (!this.visualizerContext || !this.visualizerCanvas) return;
this.visualizerContext.fillStyle = '#fafafa';
this.visualizerContext.fillRect(0, 0, this.visualizerCanvas.width, this.visualizerCanvas.height);
@@ -196,147 +569,33 @@ export class UIController {
for (let i = 0; i < dataArray.length; i++) {
barHeight = dataArray[i] / 2;
// 创建渐变色:从紫色到蓝色到青色
const hue = 200 + (barHeight / this.visualizerCanvas.height) * 60; // 200-260度,从青色到紫色
const saturation = 80 + (barHeight / this.visualizerCanvas.height) * 20; // 饱和度 80-100%
const lightness = 45 + (barHeight / this.visualizerCanvas.height) * 15; // 亮度 45-60%
// Create gradient color: from purple to blue to green
const gradient = this.visualizerContext.createLinearGradient(0, 0, 0, this.visualizerCanvas.height);
gradient.addColorStop(0, '#8e44ad');
gradient.addColorStop(0.5, '#3498db');
gradient.addColorStop(1, '#1abc9c');
this.visualizerContext.fillStyle = `hsl(${hue}, ${saturation}%, ${lightness}%)`;
this.visualizerContext.fillStyle = gradient;
this.visualizerContext.fillRect(x, this.visualizerCanvas.height - barHeight, barWidth, barHeight);
x += barWidth + 1;
}
}
// 初始化事件监听器
initEventListeners() {
const wsHandler = getWebSocketHandler();
const audioRecorder = getAudioRecorder();
// Update session status UI
updateSessionStatus(isSpeaking) {
// Here can add session status UI update logic
// For example: update Live2D model's mouth movement status
}
// 设置WebSocket回调
wsHandler.onConnectionStateChange = (isConnected) => {
this.updateConnectionUI(isConnected);
};
wsHandler.onRecordButtonStateChange = (isRecording) => {
this.updateRecordButtonState(isRecording);
};
wsHandler.onSessionStateChange = (isSpeaking) => {
this.updateSessionStatus(isSpeaking);
};
wsHandler.onSessionEmotionChange = (emoji) => {
this.updateSessionEmotion(emoji);
};
// 设置录音器回调
audioRecorder.onRecordingStart = (seconds) => {
this.updateRecordButtonState(true, seconds);
};
audioRecorder.onRecordingStop = () => {
this.updateRecordButtonState(false);
};
audioRecorder.onVisualizerUpdate = (dataArray) => {
this.drawVisualizer(dataArray);
};
// 连接按钮
const connectButton = document.getElementById('connectButton');
let isConnecting = false;
const handleConnect = async () => {
if (isConnecting) return;
if (wsHandler.isConnected()) {
wsHandler.disconnect();
} else {
isConnecting = true;
await wsHandler.connect();
isConnecting = false;
}
};
connectButton.addEventListener('click', handleConnect);
// 设备配置面板编辑/确定切换
const toggleButton = document.getElementById('toggleConfig');
const deviceMacInput = document.getElementById('deviceMac');
const deviceNameInput = document.getElementById('deviceName');
const clientIdInput = document.getElementById('clientId');
toggleButton.addEventListener('click', () => {
this.isEditing = !this.isEditing;
deviceMacInput.disabled = !this.isEditing;
deviceNameInput.disabled = !this.isEditing;
clientIdInput.disabled = !this.isEditing;
toggleButton.textContent = this.isEditing ? '确定' : '编辑';
if (!this.isEditing) {
saveConfig();
}
});
// 标签页切换
const tabs = document.querySelectorAll('.tab');
tabs.forEach(tab => {
tab.addEventListener('click', () => {
tabs.forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
tab.classList.add('active');
const tabContent = document.getElementById(`${tab.dataset.tab}Tab`);
tabContent.classList.add('active');
if (tab.dataset.tab === 'voice') {
setTimeout(() => {
this.initVisualizer();
}, 50);
}
});
});
// 发送文本消息
const messageInput = document.getElementById('messageInput');
const sendTextButton = document.getElementById('sendTextButton');
const sendMessage = () => {
const message = messageInput.value.trim();
if (message && wsHandler.sendTextMessage(message)) {
messageInput.value = '';
}
};
sendTextButton.addEventListener('click', sendMessage);
messageInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') sendMessage();
});
// 录音按钮
const recordButton = document.getElementById('recordButton');
recordButton.addEventListener('click', () => {
if (audioRecorder.isRecording) {
audioRecorder.stop();
} else {
audioRecorder.start();
}
});
// 窗口大小变化
window.addEventListener('resize', () => this.initVisualizer());
// Update session emotion
updateSessionEmotion(emoji) {
// Here can add emotion update logic
// For example: display emoji in status indicator
}
}
// 创建单例
let uiControllerInstance = null;
// Create singleton instance
export const uiController = new UIController();
export function getUIController() {
if (!uiControllerInstance) {
uiControllerInstance = new UIController();
}
return uiControllerInstance;
}
// Export class for module usage
export { UIController };
@@ -1,49 +0,0 @@
// DOM元素
const connectButton = document.getElementById('connectButton');
const serverUrlInput = document.getElementById('serverUrl');
const connectionStatus = document.getElementById('connectionStatus');
const messageInput = document.getElementById('messageInput');
const sendTextButton = document.getElementById('sendTextButton');
const recordButton = document.getElementById('recordButton');
const stopButton = document.getElementById('stopButton');
// 会话记录
const conversationDiv = document.getElementById('conversation');
const logContainer = document.getElementById('logContainer');
let visualizerCanvas = document.getElementById('audioVisualizer');
// ota 是否连接成功,修改成对应的样式
export function otaStatusStyle(flan) {
if (flan) {
document.getElementById('otaStatus').textContent = 'OTA已连接';
document.getElementById('otaStatus').style.color = 'green';
} else {
document.getElementById('otaStatus').textContent = 'OTA未连接';
document.getElementById('otaStatus').style.color = 'red';
}
}
// ota 是否连接成功,修改成对应的样式
export function getLogContainer(flan) {
return logContainer;
}
// 更新Opus库状态显示
export function updateScriptStatus(message, type) {
const statusElement = document.getElementById('scriptStatus');
if (statusElement) {
statusElement.textContent = message;
statusElement.className = `script-status ${type}`;
statusElement.style.display = 'block';
statusElement.style.width = 'auto';
}
}
// 添加消息到会话记录
export function addMessage(text, isUser = false) {
const messageDiv = document.createElement('div');
messageDiv.className = `message ${isUser ? 'user' : 'server'}`;
messageDiv.textContent = text;
conversationDiv.appendChild(messageDiv);
conversationDiv.scrollTop = conversationDiv.scrollHeight;
}
+9 -3
View File
@@ -1,6 +1,3 @@
import { getLogContainer } from '../ui/dom-helper.js'
const logContainer = getLogContainer();
// 日志记录函数
export function log(message, type = 'info') {
// 将消息按换行符分割成多行
@@ -8,6 +5,15 @@ export function log(message, type = 'info') {
const now = new Date();
// const timestamp = `[${now.toLocaleTimeString()}] `;
const timestamp = `[${now.toLocaleTimeString()}.${now.getMilliseconds().toString().padStart(3, '0')}] `;
// 检查是否存在日志容器
const logContainer = document.getElementById('logContainer');
if (!logContainer) {
// 如果日志容器不存在,只输出到控制台
console.log(`[${type.toUpperCase()}] ${message}`);
return;
}
// 为每一行创建日志条目
lines.forEach((line, index) => {
const logEntry = document.createElement('div');
+199 -155
View File
@@ -5,11 +5,16 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>小智服务器测试页面</title>
<link rel="stylesheet" href="css/test_page.css">
<link rel="stylesheet" href="css/test_page.css?v=0127">
<script>
// 检测是否使用file://协议打开
if (window.location.protocol === 'file:') {
document.addEventListener('DOMContentLoaded', function () {
// 创建背景模糊遮罩
const overlayDiv = document.createElement('div');
overlayDiv.className = 'file-protocol-overlay';
document.body.appendChild(overlayDiv);
// 创建警告框
const warningDiv = document.createElement('div');
warningDiv.id = 'fileProtocolWarning';
@@ -32,198 +37,237 @@
</head>
<body>
<!-- 背景容器 -->
<div class="background-container" id="backgroundContainer">
<div class="background-overlay"></div>
</div>
<!-- 主容器 -->
<div class="container">
<div id="scriptStatus" style="position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);">
正在加载Opus库...
<!-- 连接状态指示器 - 页面顶部中部 -->
<div class="connection-status-top">
<div class="status-indicator">
<span class="status-dot status-disconnected"></span>
<span id="connectionStatus">离线</span>
</div>
</div>
<!-- 两栏布局:设备配置和连接信息 -->
<div class="two-column-layout">
<!-- 设备配置面板 -->
<div class="section">
<h2>
设备配置
<button class="toggle-button" id="toggleConfig">编辑</button>
</h2>
<div class="config-panel" id="configPanel">
<div class="control-panel">
<div class="config-row">
<div class="config-item">
<label for="deviceMac">设备MAC:</label>
<input type="text" id="deviceMac" placeholder="device-id" disabled>
<!-- 模型加载提示 -->
<div class="model-container">
<div class="model-loading" id="modelLoading">
<div class="loading-char-float">
<div class="main-char">
<span></span><span></span><span></span><span></span><span></span><span></span>
</div>
<div class="shadow-char">模型加载中✨</div>
</div>
</div>
</div>
<!-- Live2D Canvas 容器 -->
<canvas id="live2d-stage"></canvas>
<!-- 聊天消息流(弹幕样式) -->
<div class="chat-container">
<div class="chat-stream" id="chatStream">
<!-- 聊天消息将动态插入这里 -->
</div>
<div class="chat-ipt" id="chatIpt">
<input type="text" id="messageInput" autocomplete="off" placeholder="输入消息,按Enter发送">
</div>
</div>
<!-- 底部控制栏 -->
<div class="control-bar">
<button class="control-btn" id="settingsBtn" title="设置">
<svg class="btn-icon" viewBox="0 0 24 24" fill="currentColor">
<path
d="M12 15.5A3.5 3.5 0 0 1 8.5 12A3.5 3.5 0 0 1 12 8.5A3.5 3.5 0 0 1 15.5 12A3.5 3.5 0 0 1 12 15.5M19.43 12.97C19.47 12.65 19.5 12.33 19.5 12C19.5 11.67 19.47 11.34 19.43 11L21.54 9.37C21.73 9.22 21.78 8.95 21.66 8.73L19.66 5.27C19.54 5.05 19.27 4.96 19.05 5.05L16.56 6.05C16.04 5.66 15.5 5.32 14.87 5.07L14.5 2.42C14.46 2.18 14.25 2 14 2H10C9.75 2 9.54 2.18 9.5 2.42L9.13 5.07C8.5 5.32 7.96 5.66 7.44 6.05L4.95 5.05C4.73 4.96 4.46 5.05 4.34 5.27L2.34 8.73C2.22 8.95 2.27 9.22 2.46 9.37L4.57 11C4.53 11.34 4.5 11.67 4.5 12C4.5 12.33 4.53 12.65 4.57 12.97L2.46 14.63C2.27 14.78 2.22 15.05 2.34 15.27L4.34 18.73C4.46 18.95 4.73 19.03 4.95 18.95L7.44 17.94C7.96 18.34 8.5 18.68 9.13 18.93L9.5 21.58C9.54 21.82 9.75 22 10 22H14C14.25 22 14.46 21.82 14.5 21.58L14.87 18.93C15.5 18.68 16.04 18.34 16.56 17.94L19.05 18.95C19.27 19.03 19.54 18.95 19.66 18.73L21.66 15.27C21.78 15.05 21.73 14.78 21.54 14.63L19.43 12.97Z" />
</svg>
<span class="btn-text">设置</span>
</button>
<button class="control-btn" id="backgroundBtn" title="切换背景">
<svg class="btn-icon" viewBox="0 0 24 24" fill="currentColor">
<path
d="M12,18A6,6 0 0,1 6,12C6,11 6.25,10.03 6.7,9.2L5.24,7.74C4.46,8.97 4,10.43 4,12A8,8 0 0,0 12,20V23L16,19L12,15V18M12,4V1L8,5L12,9V6A6,6 0 0,1 18,12C18,13 17.75,13.97 17.3,14.8L18.76,16.26C19.54,15.03 20,13.57 20,12A8,8 0 0,0 12,4Z" />
</svg>
<span class="btn-text">背景</span>
</button>
<button class="control-btn dial-btn" id="dialBtn" title="拨号">
<svg class="btn-icon" viewBox="0 0 24 24" fill="currentColor">
<path
d="M6.62,10.79C8.06,13.62 10.38,15.94 13.21,17.38L15.41,15.18C15.69,14.9 16.08,14.82 16.43,14.93C17.55,15.3 18.75,15.5 20,15.5A1,1 0 0,1 21,16.5V20A1,1 0 0,1 20,21A17,17 0 0,1 3,4A1,1 0 0,1 4,3H7.5A1,1 0 0,1 8.5,4C8.5,5.25 8.7,6.45 9.07,7.57C9.18,7.92 9.1,8.31 8.82,8.59L6.62,10.79Z" />
</svg>
<span class="btn-text">拨号</span>
</button>
<button class="control-btn" id="recordBtn" title="开始录音" disabled>
<svg class="btn-icon" viewBox="0 0 24 24" fill="currentColor">
<path
d="M12,2A3,3 0 0,1 15,5V11A3,3 0 0,1 12,14A3,3 0 0,1 9,11V5A3,3 0 0,1 12,2M19,11C19,14.53 16.39,17.44 13,17.93V21H11V17.93C7.61,17.44 5,14.53 5,11H7A5,5 0 0,0 12,16A5,5 0 0,0 17,11H19Z" />
</svg>
<span class="btn-text">录音</span>
</button>
</div>
</div>
<!-- 设置弹窗 -->
<div class="modal" id="settingsModal">
<div class="modal-content settings-modal">
<div class="modal-header">
<h2>设置</h2>
<button class="close-btn" id="closeSettingsBtn">&times;</button>
</div>
<div class="modal-body">
<div class="settings-tabs">
<button class="tab-btn active" data-tab="device">设备配置</button>
<button class="tab-btn" data-tab="mcp">MCP工具</button>
</div>
<div class="tab-content active" id="deviceTab">
<!-- 设备配置面板 -->
<div class="config-panel">
<div class="control-panel">
<div class="config-row">
<div class="config-item">
<label for="deviceMac">设备MAC:</label>
<input type="text" id="deviceMac" placeholder="device-id">
</div>
</div>
<div class="config-item">
<label for="clientId">客户端ID:</label>
<input type="text" id="clientId" value="web_test_client" placeholder="client-id"
disabled>
<div class="config-row">
<div class="config-item">
<label for="clientId">客户端ID:</label>
<input type="text" id="clientId" value="web_test_client" placeholder="client-id">
</div>
<div class="config-item">
<label for="deviceName">设备名称:</label>
<input type="text" id="deviceName" value="Web测试设备" maxlength="50"
placeholder="deviceName">
</div>
</div>
</div>
<div class="config-row">
<div class="config-item">
<label for="deviceName">设备名称:</label>
<input type="text" id="deviceName" value="Web测试设备" placeholder="deviceName" disabled>
</div>
<!-- 连接信息面板 -->
<div class="connection-controls">
<div class="input-group">
<label for="otaUrl">OTA服务器地址:</label>
<input type="text" id="otaUrl" value="http://127.0.0.1:8002/xiaozhi/ota/"
placeholder="OTA服务器地址,如:http://127.0.0.1:8002/xiaozhi/ota/" />
</div>
<div class="input-group">
<label for="serverUrl">WebSocket服务器地址:</label>
<input type="text" id="serverUrl" value="" readonly disabled
placeholder="填写OTA地址后,点击拨号按钮自动连接" />
</div>
</div>
</div>
<div class="tab-content" id="mcpTab">
<!-- MCP 工具管理区域 -->
<div class="mcp-tools-container">
<div class="mcp-tools-header">
<h3>MCP 工具管理</h3>
<button class="btn-secondary" id="toggleMcpTools">
收起
</button>
</div>
<div class="mcp-tools-panel" id="mcpToolsPanel">
<div class="mcp-tools-list" id="mcpToolsContainer">
<!-- 工具列表将动态插入这里 -->
</div>
<div class="mcp-actions">
<button class="btn-primary" id="addMcpToolBtn">
➕ 添加新工具
</button>
</div>
</div>
</div>
</div>
</div>
<!-- 连接信息面板 -->
<div class="section">
<h2>
连接信息
<span class="connection-status">
<span id="otaStatus" class="status">● OTA未连接</span>
<span id="connectionStatus" class="status">● WS未连接</span>
</span>
<button class="connect-button" id="connectButton">
<span></span>
连接
</button>
</h2>
<div class="connection-controls">
<input type="text" id="otaUrl" value="http://127.0.0.1:8002/xiaozhi/ota/"
placeholder="OTA服务器地址,如:http://127.0.0.1:8002/xiaozhi/ota/" />
<input type="text" id="serverUrl" value="" readonly disabled placeholder="点击连接按钮后,自动从OTA接口获取" />
</div>
</div>
</div>
</div>
<div class="section">
<div class="tabs">
<button class="tab active" data-tab="text">文本消息</button>
<button class="tab" data-tab="voice">语音消息</button>
<!-- MCP 工具编辑模态框 -->
<div id="mcpToolModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2 id="mcpModalTitle">添加工具</h2>
<button class="close-btn" id="closeMcpModalBtn">&times;</button>
</div>
<div class="tab-content active" id="textTab">
<div class="message-input">
<input type="text" id="messageInput" placeholder="输入消息..." disabled>
<button id="sendTextButton" disabled>发送</button>
</div>
</div>
<div class="tab-content" id="voiceTab">
<div class="audio-controls">
<button id="recordButton" class="record-button" disabled>开始录音</button>
</div>
<canvas id="audioVisualizer" class="audio-visualizer"></canvas>
</div>
<div style="margin: -10px 0px 10px 0px;">
<span class="connection-status llm-emoji">
<span id="sessionStatus" class="status offline" style="position: relative; display: inline-block;">
<span id="sessionStatusBg"
style="position: absolute; left: 0; top: 0; bottom: 0; width: 0%; background: linear-gradient(90deg, rgba(76, 175, 80, 0.2), rgba(33, 150, 243, 0.2)); transition: width 0.15s ease-out, background 0.3s ease; z-index: 0; border-radius: 20px;"></span>
<span style="position: relative; z-index: 1;"><span class="emoji-large">😶</span> 小智离线中</span>
</span>
</span>
</div>
<div class="flex-container">
<div id="conversation" class="conversation"></div>
<div id="logContainer">
<div class="log-entry log-info">准备就绪,请连接服务器开始测试...</div>
</div>
</div>
</div>
<!-- MCP 工具管理区域 -->
<div class="section">
<h2>
MCP 工具管理
<span class="connection-status">
<span id="mcpToolsCount">0 个工具</span>
</span>
<button class="toggle-button" id="toggleMcpTools">展开</button>
</h2>
<div class="config-panel" id="mcpToolsPanel">
<div id="mcpToolsContainer" class="mcp-tools-container">
<!-- 工具列表将动态插入这里 -->
</div>
<div style="text-align: center; padding: 15px 0;">
<button class="btn" id="addMcpToolBtn" style="background-color: #4caf50;">
➕ 添加新工具
</button>
</div>
</div>
</div>
<!-- MCP 工具编辑模态框 -->
<div id="mcpToolModal"
style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.5); z-index: 9999; overflow-y: auto;">
<div
style="background-color: white; border-radius: 10px; padding: 25px; width: 90%; max-width: 700px; margin: 50px auto; max-height: 85vh; overflow-y: auto;">
<div
style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; padding-bottom: 15px; border-bottom: 2px solid #e0e0e0;">
<h2 id="mcpModalTitle" style="font-size: 20px; font-weight: bold; color: #333; margin: 0;">添加工具</h2>
<button id="closeMcpModalBtn"
style="background: none; border: none; font-size: 24px; cursor: pointer; color: #666; padding: 0; width: 30px; height: 30px;">&times;</button>
</div>
<div class="modal-body">
<div id="mcpErrorContainer"></div>
<form id="mcpToolForm">
<div style="margin-bottom: 20px;">
<label
style="display: block; margin-bottom: 8px; font-weight: 500; color: #333; font-size: 14px;">工具名称
*</label>
<input type="text" id="mcpToolName" placeholder="例如: self.get_device_status" required
style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 5px; font-size: 14px;">
<div class="input-group">
<label for="mcpToolName">工具名称 *</label>
<input type="text" id="mcpToolName" placeholder="例如: self.get_device_status" required>
</div>
<div style="margin-bottom: 20px;">
<label
style="display: block; margin-bottom: 8px; font-weight: 500; color: #333; font-size: 14px;">工具描述
*</label>
<textarea id="mcpToolDescription" placeholder="详细描述工具的功能和使用场景..." required
style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 5px; font-size: 14px; min-height: 80px; resize: vertical;"></textarea>
<div class="input-group">
<label for="mcpToolDescription">工具描述 *</label>
<textarea id="mcpToolDescription" placeholder="详细描述工具的功能和使用场景..." required></textarea>
</div>
<div style="margin-bottom: 20px;">
<label
style="display: block; margin-bottom: 8px; font-weight: 500; color: #333; font-size: 14px;">输入参数</label>
<div
style="background-color: #f9f9f9; border: 1px solid #ddd; border-radius: 5px; padding: 15px;">
<div class="input-group">
<label>输入参数</label>
<div class="properties-container">
<div id="mcpPropertiesContainer">
<div style="text-align: center; padding: 20px; color: #999; font-size: 14px;">
暂无参数,点击下方按钮添加参数</div>
暂无参数,点击下方按钮添加参数
</div>
</div>
<button type="button" id="addMcpPropertyBtn"
style="width: 100%; margin-top: 10px; padding: 8px 15px; border: none; border-radius: 5px; background-color: #2196f3; color: white; cursor: pointer; font-size: 14px;">
<button type="button" class="btn-secondary" id="addMcpPropertyBtn">
➕ 添加参数
</button>
</div>
</div>
<div style="margin-bottom: 20px;">
<label
style="display: block; margin-bottom: 8px; font-weight: 500; color: #333; font-size: 14px;">
<div class="input-group">
<label for="mcpMockResponse">
模拟返回结果 (JSON 格式,可选)
<span style="font-size: 12px; color: #999; font-weight: normal;">- 留空则返回默认成功消息</span>
</label>
<textarea id="mcpMockResponse" placeholder='{"success": true, "data": "执行成功"}'
style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 5px; font-size: 13px; font-family: 'Courier New', monospace; min-height: 100px; resize: vertical;"></textarea>
<div style="font-size: 12px; color: #666; margin-top: 4px;">
💡 提示:如果设置了模拟返回结果,工具调用时将返回这个 JSON 对象
</div>
<textarea id="mcpMockResponse" placeholder='{"success": true, "data": "执行成功"}'></textarea>
</div>
<div style="display: flex; gap: 10px; justify-content: flex-end; margin-top: 25px;">
<button type="button" id="cancelMcpBtn"
style="padding: 8px 15px; border: none; border-radius: 5px; background-color: #9e9e9e; color: white; cursor: pointer; font-size: 14px;">取消</button>
<button type="submit"
style="padding: 8px 15px; border: none; border-radius: 5px; background-color: #4caf50; color: white; cursor: pointer; font-size: 14px;">保存</button>
<div class="modal-actions">
<button type="button" class="btn-secondary" id="cancelMcpBtn">取消</button>
<button type="submit" class="btn-primary">保存</button>
</div>
</form>
</div>
</div>
</div>
<!-- 背景加载 -->
<script src="js/ui/background-load.js?v=0127"></script>
<!-- PIXI.js 2D渲染引擎 -->
<script src="js/live2d/pixi.js?v=0127"></script>
<!-- Live2D Cubism 4.0 SDK -->
<script src="js/live2d/live2dcubismcore.min.js?v=0127"></script>
<script src="js/live2d/cubism4.min.js?v=0127"></script>
<!-- Live2D 管理器 -->
<script src="js/live2d/live2d.js?v=0127"></script>
<!-- Opus解码库 -->
<script src="js/utils/libopus.js"></script>
<script src="js/utils/libopus.js?v=0127"></script>
<!-- 主应用入口 -->
<script type="module" src="js/app.js"></script>
<script type="module" src="js/app.js?v=0127"></script>
<!-- 全局错误处理 -->
<script>
// 添加全局错误处理
window.addEventListener('error', function (event) {
console.error('全局错误:', event.error);
});
// 添加未处理的Promise拒绝处理
window.addEventListener('unhandledrejection', function (event) {
console.error('未处理的Promise拒绝:', event.reason);
});
</script>
</body>
</html>