mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-28 22:33:52 +08:00
Merge branch 'main' into py_test_Memory_powermem
This commit is contained in:
@@ -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/streaming,http默认http://127.0.0.1:8090/paddlespeech/tts]
|
||||
spk_id: 0 # 发音人 ID,0 通常表示默认的发音人
|
||||
sample_rate: 24000 # 采样率 [websocket默认24000,http默认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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
"""构建请求体"""
|
||||
|
||||
@@ -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。为保持兼容,这里回退到普通文本流式输出。
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 × 16 kHz × 1 ch × 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
|
||||
|
||||
@@ -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.
Binary file not shown.
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"
|
||||
]
|
||||
]
|
||||
}
|
||||
Binary file not shown.
@@ -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 |
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
})();
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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">×</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">×</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;">×</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>
|
||||
Reference in New Issue
Block a user