mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-24 08:03:53 +08:00
Merge branch 'main' into function-call-v2
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -4,6 +4,17 @@ import requests
|
||||
import yaml
|
||||
import time
|
||||
|
||||
|
||||
class DeviceNotFoundException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class DeviceBindException(Exception):
|
||||
def __init__(self, bind_code):
|
||||
self.bind_code = bind_code
|
||||
super().__init__(f"设备绑定异常,绑定码: {bind_code}")
|
||||
|
||||
|
||||
# 添加全局配置缓存
|
||||
_config_cache = None
|
||||
|
||||
@@ -83,7 +94,11 @@ def _make_api_request(api_url, secret, endpoint, json_data=None):
|
||||
response = requests.post(f"{api_url}{endpoint}", json=json_data)
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
if result.get("code") != 0:
|
||||
if result.get("code") == 10041:
|
||||
raise DeviceNotFoundException(result.get("msg"))
|
||||
elif result.get("code") == 10042:
|
||||
raise DeviceBindException(result.get("msg"))
|
||||
elif result.get("code") != 0:
|
||||
raise Exception(f"API返回错误: {result.get('msg', '未知错误')}")
|
||||
return result.get("data")
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import sys
|
||||
from loguru import logger
|
||||
from config.config_loader import load_config
|
||||
|
||||
SERVER_VERSION = "0.3.3"
|
||||
SERVER_VERSION = "0.3.5"
|
||||
|
||||
|
||||
def get_module_abbreviation(module_name, module_dict):
|
||||
|
||||
@@ -27,7 +27,11 @@ from core.handle.functionHandler import FunctionHandler
|
||||
from plugins_func.register import Action, ActionResponse
|
||||
from core.auth import AuthMiddleware, AuthenticationError
|
||||
from core.mcp.manager import MCPManager
|
||||
from config.config_loader import get_private_config_from_api
|
||||
from config.config_loader import (
|
||||
get_private_config_from_api,
|
||||
DeviceNotFoundException,
|
||||
DeviceBindException,
|
||||
)
|
||||
|
||||
TAG = __name__
|
||||
|
||||
@@ -46,6 +50,9 @@ class ConnectionHandler:
|
||||
self.logger = setup_logging()
|
||||
self.auth = AuthMiddleware(config)
|
||||
|
||||
self.need_bind = False
|
||||
self.bind_code = None
|
||||
|
||||
self.websocket = None
|
||||
self.headers = None
|
||||
self.client_ip = None
|
||||
@@ -109,6 +116,27 @@ class ConnectionHandler:
|
||||
try:
|
||||
# 获取并验证headers
|
||||
self.headers = dict(ws.request.headers)
|
||||
|
||||
if self.headers.get("device-id", None) is None:
|
||||
# 尝试从 URL 的查询参数中获取 device-id
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
# 从 WebSocket 请求中获取路径
|
||||
request_path = ws.request.path
|
||||
if not request_path:
|
||||
self.logger.bind(tag=TAG).error("无法获取请求路径")
|
||||
return
|
||||
parsed_url = urlparse(request_path)
|
||||
query_params = parse_qs(parsed_url.query)
|
||||
if "device-id" in query_params:
|
||||
self.headers["device-id"] = query_params["device-id"][0]
|
||||
self.headers["client-id"] = query_params["client-id"][0]
|
||||
else:
|
||||
self.logger.bind(tag=TAG).error(
|
||||
"无法从请求头和URL查询参数中获取device-id"
|
||||
)
|
||||
return
|
||||
|
||||
# 获取客户端ip地址
|
||||
self.client_ip = ws.remote_address[0]
|
||||
self.logger.bind(tag=TAG).info(
|
||||
@@ -177,7 +205,6 @@ class ConnectionHandler:
|
||||
self._initialize_models()
|
||||
|
||||
"""加载提示词"""
|
||||
self.prompt = self.config["prompt"]
|
||||
self.dialogue.put(Message(role="system", content=self.prompt))
|
||||
|
||||
"""加载记忆"""
|
||||
@@ -206,7 +233,15 @@ class ConnectionHandler:
|
||||
)
|
||||
private_config["delete_audio"] = bool(self.config.get("delete_audio", True))
|
||||
self.logger.bind(tag=TAG).info(f"获取差异化配置成功: {private_config}")
|
||||
except DeviceNotFoundException as e:
|
||||
self.need_bind = True
|
||||
private_config = {}
|
||||
except DeviceBindException as e:
|
||||
self.need_bind = True
|
||||
self.bind_code = e.bind_code
|
||||
private_config = {}
|
||||
except Exception as e:
|
||||
self.need_bind = True
|
||||
self.logger.bind(tag=TAG).error(f"获取差异化配置失败: {e}")
|
||||
private_config = {}
|
||||
|
||||
@@ -254,8 +289,6 @@ class ConnectionHandler:
|
||||
self.config["selected_module"]["Intent"] = private_config[
|
||||
"selected_module"
|
||||
]["Intent"]
|
||||
if private_config.get("prompt", None) is not None:
|
||||
self.config["prompt"] = private_config["prompt"]
|
||||
try:
|
||||
modules = initialize_modules(
|
||||
self.logger,
|
||||
@@ -270,6 +303,10 @@ class ConnectionHandler:
|
||||
except Exception as e:
|
||||
self.logger.bind(tag=TAG).error(f"初始化组件失败: {e}")
|
||||
modules = {}
|
||||
if modules.get("vad", None) is not None:
|
||||
self.vad = modules["vad"]
|
||||
if modules.get("asr", None) is not None:
|
||||
self.asr = modules["asr"]
|
||||
if modules.get("tts", None) is not None:
|
||||
self.tts = modules["tts"]
|
||||
if modules.get("llm", None) is not None:
|
||||
@@ -278,6 +315,8 @@ class ConnectionHandler:
|
||||
self.intent = modules["intent"]
|
||||
if modules.get("memory", None) is not None:
|
||||
self.memory = modules["memory"]
|
||||
if modules.get("prompt", None) is not None:
|
||||
self.change_system_prompt(modules["prompt"])
|
||||
|
||||
def _initialize_memory(self):
|
||||
"""初始化记忆模块"""
|
||||
@@ -345,7 +384,6 @@ class ConnectionHandler:
|
||||
response_message = []
|
||||
processed_chars = 0 # 跟踪已处理的字符位置
|
||||
try:
|
||||
start_time = time.time()
|
||||
# 使用带记忆的对话
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
self.memory.query_memory(query), self.loop
|
||||
@@ -367,9 +405,6 @@ class ConnectionHandler:
|
||||
if self.client_abort:
|
||||
break
|
||||
|
||||
end_time = time.time()
|
||||
# self.logger.bind(tag=TAG).debug(f"大模型返回时间: {end_time - start_time} 秒, 生成token={content}")
|
||||
|
||||
# 合并当前全部文本并处理未分割部分
|
||||
full_text = "".join(response_message)
|
||||
current_text = full_text[processed_chars:] # 从未处理的位置开始
|
||||
|
||||
@@ -81,6 +81,8 @@ async def wakeupWordsResponse(conn):
|
||||
"""唤醒词响应"""
|
||||
wakeup_word = random.choice(WAKEUP_CONFIG["words"])
|
||||
result = conn.llm.response_no_stream(conn.config["prompt"], wakeup_word)
|
||||
if result is None or result == "":
|
||||
return
|
||||
tts_file = await asyncio.to_thread(conn.tts.to_tts, result)
|
||||
|
||||
if tts_file is not None and os.path.exists(tts_file):
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from config.logger import setup_logging
|
||||
import time
|
||||
import asyncio
|
||||
from core.utils.util import remove_punctuation_and_length
|
||||
from core.handle.sendAudioHandle import send_stt_message
|
||||
from core.handle.intentHandler import handle_user_intent
|
||||
@@ -35,9 +36,7 @@ async def handleAudioMessage(conn, audio):
|
||||
if len(conn.asr_audio) < 15:
|
||||
conn.asr_server_receive = True
|
||||
else:
|
||||
text, file_path = await conn.asr.speech_to_text(
|
||||
conn.asr_audio, conn.session_id
|
||||
)
|
||||
text, _ = await conn.asr.speech_to_text(conn.asr_audio, conn.session_id)
|
||||
logger.bind(tag=TAG).info(f"识别文本: {text}")
|
||||
text_len, _ = remove_punctuation_and_length(text)
|
||||
if text_len > 0:
|
||||
@@ -49,6 +48,9 @@ async def handleAudioMessage(conn, audio):
|
||||
|
||||
|
||||
async def startToChat(conn, text):
|
||||
if conn.need_bind:
|
||||
await check_bind_device(conn)
|
||||
return
|
||||
# 首先进行意图分析
|
||||
intent_handled = await handle_user_intent(conn, text)
|
||||
|
||||
@@ -85,3 +87,44 @@ async def no_voice_close_connect(conn):
|
||||
"请你以“时间过得真快”未来头,用富有感情、依依不舍的话来结束这场对话吧。"
|
||||
)
|
||||
await startToChat(conn, prompt)
|
||||
|
||||
|
||||
async def check_bind_device(conn):
|
||||
if conn.bind_code:
|
||||
# 确保bind_code是6位数字
|
||||
if len(conn.bind_code) != 6:
|
||||
logger.bind(tag=TAG).error(f"无效的绑定码格式: {conn.bind_code}")
|
||||
text = "绑定码格式错误,请检查配置。"
|
||||
await send_stt_message(conn, text)
|
||||
return
|
||||
|
||||
text = f"请登录控制面板,输入{conn.bind_code},绑定设备。"
|
||||
await send_stt_message(conn, text)
|
||||
conn.tts_first_text_index = 0
|
||||
conn.tts_last_text_index = 6
|
||||
conn.llm_finish_task = True
|
||||
|
||||
# 播放提示音
|
||||
music_path = "config/assets/bind_code.wav"
|
||||
opus_packets, _ = conn.tts.audio_to_opus_data(music_path)
|
||||
conn.audio_play_queue.put((opus_packets, text, 0))
|
||||
|
||||
# 逐个播放数字
|
||||
for i in range(6): # 确保只播放6位数字
|
||||
try:
|
||||
digit = conn.bind_code[i]
|
||||
num_path = f"config/assets/bind_code/{digit}.wav"
|
||||
num_packets, _ = conn.tts.audio_to_opus_data(num_path)
|
||||
conn.audio_play_queue.put((num_packets, None, i + 1))
|
||||
except Exception as e:
|
||||
logger.bind(tag=TAG).error(f"播放数字音频失败: {e}")
|
||||
continue
|
||||
else:
|
||||
text = f"没有找到该设备的版本信息,请正确配置 OTA地址,然后重新编译固件。"
|
||||
await send_stt_message(conn, text)
|
||||
conn.tts_first_text_index = 0
|
||||
conn.tts_last_text_index = 0
|
||||
conn.llm_finish_task = True
|
||||
music_path = "config/assets/bind_not_found.wav"
|
||||
opus_packets, _ = conn.tts.audio_to_opus_data(music_path)
|
||||
conn.audio_play_queue.put((opus_packets, text, 0))
|
||||
|
||||
@@ -3,7 +3,6 @@ import json
|
||||
import asyncio
|
||||
import time
|
||||
from core.utils.util import (
|
||||
remove_punctuation_and_length,
|
||||
get_string_no_punctuation_or_emoji,
|
||||
)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import json
|
||||
import re
|
||||
from core.providers.llm.base import LLMProviderBase
|
||||
import os
|
||||
|
||||
# official coze sdk for Python [cozepy](https://github.com/coze-dev/coze-py)
|
||||
from cozepy import COZE_CN_BASE_URL
|
||||
from cozepy import Coze, TokenAuth, Message, ChatStatus, MessageContentType, ChatEventType # noqa
|
||||
@@ -16,8 +17,8 @@ logger = setup_logging()
|
||||
class LLMProvider(LLMProviderBase):
|
||||
def __init__(self, config):
|
||||
self.personal_access_token = config.get("personal_access_token")
|
||||
self.bot_id = config.get("bot_id")
|
||||
self.user_id = config.get("user_id")
|
||||
self.bot_id = str(config.get("bot_id"))
|
||||
self.user_id = str(config.get("user_id"))
|
||||
self.session_conversation_map = {} # 存储session_id和conversation_id的映射
|
||||
|
||||
def response(self, session_id, dialogue):
|
||||
@@ -25,16 +26,13 @@ class LLMProvider(LLMProviderBase):
|
||||
coze_api_base = COZE_CN_BASE_URL
|
||||
|
||||
last_msg = next(m for m in reversed(dialogue) if m["role"] == "user")
|
||||
|
||||
|
||||
coze = Coze(auth=TokenAuth(token=coze_api_token), base_url=coze_api_base)
|
||||
conversation_id = self.session_conversation_map.get(session_id)
|
||||
|
||||
# 如果没有找到conversation_id,则创建新的对话
|
||||
if not conversation_id:
|
||||
conversation = coze.conversations.create(
|
||||
messages=[
|
||||
]
|
||||
)
|
||||
conversation = coze.conversations.create(messages=[])
|
||||
conversation_id = conversation.id
|
||||
self.session_conversation_map[session_id] = conversation_id # 更新映射
|
||||
|
||||
@@ -68,4 +66,4 @@ class LLMProvider(LLMProviderBase):
|
||||
dialogue.pop()
|
||||
|
||||
for token in self.response(session_id, dialogue):
|
||||
yield token, None
|
||||
yield token, None
|
||||
|
||||
@@ -8,7 +8,7 @@ from pydantic import BaseModel, Field, conint, model_validator
|
||||
from typing_extensions import Annotated
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
from core.utils.util import check_model_key
|
||||
from core.utils.util import check_model_key, parse_string_to_list
|
||||
from core.providers.tts.base import TTSProviderBase
|
||||
from config.logger import setup_logging
|
||||
|
||||
@@ -86,8 +86,8 @@ class TTSProvider(TTSProviderBase):
|
||||
super().__init__(config, delete_audio_file)
|
||||
|
||||
self.reference_id = config.get("reference_id")
|
||||
self.reference_audio = config.get("reference_audio", [])
|
||||
self.reference_text = config.get("reference_text", [])
|
||||
self.reference_audio = parse_string_to_list(config.get("reference_audio"))
|
||||
self.reference_text = parse_string_to_list(config.get("reference_text"))
|
||||
self.format = config.get("format", "wav")
|
||||
self.channels = int(config.get("channels", 1))
|
||||
self.rate = int(config.get("rate", 44100))
|
||||
@@ -101,9 +101,13 @@ class TTSProvider(TTSProviderBase):
|
||||
self.top_p = float(config.get("top_p", 0.7))
|
||||
self.repetition_penalty = float(config.get("repetition_penalty", 1.2))
|
||||
self.temperature = float(config.get("temperature", 0.7))
|
||||
self.streaming = bool(config.get("streaming", False))
|
||||
self.streaming = str(config.get("streaming", False)).lower() in (
|
||||
"true",
|
||||
"1",
|
||||
"yes",
|
||||
)
|
||||
self.use_memory_cache = config.get("use_memory_cache", "on")
|
||||
self.seed = config.get("seed")
|
||||
self.seed = config.get("seed") or None
|
||||
self.api_url = config.get("api_url", "http://127.0.0.1:8080/v1/tts")
|
||||
|
||||
def generate_filename(self, extension=".wav"):
|
||||
|
||||
@@ -6,6 +6,7 @@ import requests
|
||||
from config.logger import setup_logging
|
||||
from datetime import datetime
|
||||
from core.providers.tts.base import TTSProviderBase
|
||||
from core.utils.util import parse_string_to_list
|
||||
|
||||
TAG = __name__
|
||||
logger = setup_logging()
|
||||
@@ -25,14 +26,33 @@ class TTSProvider(TTSProviderBase):
|
||||
self.text_split_method = config.get("text_split_method", "cut0")
|
||||
self.batch_size = int(config.get("batch_size", 1))
|
||||
self.batch_threshold = float(config.get("batch_threshold", 0.75))
|
||||
self.split_bucket = bool(config.get("split_bucket", True))
|
||||
self.return_fragment = bool(config.get("return_fragment", False))
|
||||
|
||||
self.split_bucket = str(config.get("split_bucket", True)).lower() in (
|
||||
"true",
|
||||
"1",
|
||||
"yes",
|
||||
)
|
||||
self.return_fragment = str(config.get("return_fragment", False)).lower() in (
|
||||
"true",
|
||||
"1",
|
||||
"yes",
|
||||
)
|
||||
self.speed_factor = float(config.get("speed_factor", 1.0))
|
||||
self.streaming_mode = bool(config.get("streaming_mode", False))
|
||||
self.streaming_mode = str(config.get("streaming_mode", False)).lower() in (
|
||||
"true",
|
||||
"1",
|
||||
"yes",
|
||||
)
|
||||
self.seed = int(config.get("seed", -1))
|
||||
self.parallel_infer = bool(config.get("parallel_infer", True))
|
||||
self.parallel_infer = str(config.get("parallel_infer", True)).lower() in (
|
||||
"true",
|
||||
"1",
|
||||
"yes",
|
||||
)
|
||||
self.repetition_penalty = float(config.get("repetition_penalty", 1.35))
|
||||
self.aux_ref_audio_paths = config.get("aux_ref_audio_paths", [])
|
||||
self.aux_ref_audio_paths = parse_string_to_list(
|
||||
config.get("aux_ref_audio_paths")
|
||||
)
|
||||
|
||||
def generate_filename(self, extension=".wav"):
|
||||
return os.path.join(
|
||||
|
||||
@@ -4,6 +4,7 @@ import requests
|
||||
from config.logger import setup_logging
|
||||
from datetime import datetime
|
||||
from core.providers.tts.base import TTSProviderBase
|
||||
from core.utils.util import parse_string_to_list
|
||||
|
||||
TAG = __name__
|
||||
logger = setup_logging()
|
||||
@@ -22,9 +23,9 @@ class TTSProvider(TTSProviderBase):
|
||||
self.temperature = float(config.get("temperature", 1.0))
|
||||
self.cut_punc = config.get("cut_punc", "")
|
||||
self.speed = float(config.get("speed", 1.0))
|
||||
self.inp_refs = config.get("inp_refs", [])
|
||||
self.inp_refs = parse_string_to_list(config.get("inp_refs"))
|
||||
self.sample_steps = int(config.get("sample_steps", 32))
|
||||
self.if_sr = bool(config.get("if_sr", False))
|
||||
self.if_sr = str(config.get("if_sr", False)).lower() in ("true", "1", "yes")
|
||||
|
||||
def generate_filename(self, extension=".wav"):
|
||||
return os.path.join(
|
||||
|
||||
@@ -4,6 +4,7 @@ import json
|
||||
import requests
|
||||
from datetime import datetime
|
||||
from core.providers.tts.base import TTSProviderBase
|
||||
from core.utils.util import parse_string_to_list
|
||||
|
||||
|
||||
class TTSProvider(TTSProviderBase):
|
||||
@@ -40,7 +41,7 @@ class TTSProvider(TTSProviderBase):
|
||||
**config.get("pronunciation_dict", {}),
|
||||
}
|
||||
self.audio_setting = {**defult_audio_setting, **config.get("audio_setting", {})}
|
||||
self.timber_weights = config.get("timber_weights", [])
|
||||
self.timber_weights = parse_string_to_list(config.get("timber_weights"))
|
||||
|
||||
if self.voice_id:
|
||||
self.voice_setting["voice_id"] = self.voice_id
|
||||
|
||||
@@ -16,7 +16,7 @@ class TTSProvider(TTSProviderBase):
|
||||
self.voice = config.get("voice")
|
||||
self.response_format = config.get("response_format")
|
||||
self.sample_rate = config.get("sample_rate")
|
||||
self.speed = float(config.get("speed"))
|
||||
self.speed = float(config.get("speed", 1.0))
|
||||
self.gain = config.get("gain")
|
||||
|
||||
self.host = "api.siliconflow.cn"
|
||||
|
||||
@@ -22,7 +22,7 @@ class TTSProvider(TTSProviderBase):
|
||||
self.to_lang = config.get("to_lang")
|
||||
self.volume_change_dB = int(config.get("volume_change_dB", 0))
|
||||
self.speed_factor = int(config.get("speed_factor", 1))
|
||||
self.stream = bool(config.get("stream", False))
|
||||
self.stream = str(config.get("stream", False)).lower() in ("true", "1", "yes")
|
||||
self.output_file = config.get("output_dir")
|
||||
self.pitch_factor = int(config.get("pitch_factor", 0))
|
||||
self.format = config.get("format", "mp3")
|
||||
|
||||
@@ -163,6 +163,24 @@ def check_model_key(modelType, modelKey):
|
||||
return True
|
||||
|
||||
|
||||
def parse_string_to_list(value, separator=";"):
|
||||
"""
|
||||
将输入值转换为列表
|
||||
Args:
|
||||
value: 输入值,可以是 None、字符串或列表
|
||||
separator: 分隔符,默认为分号
|
||||
Returns:
|
||||
list: 处理后的列表
|
||||
"""
|
||||
if value is None or value == "":
|
||||
return []
|
||||
elif isinstance(value, str):
|
||||
return [item.strip() for item in value.split(separator) if item.strip()]
|
||||
elif isinstance(value, list):
|
||||
return value
|
||||
return []
|
||||
|
||||
|
||||
def check_ffmpeg_installed():
|
||||
ffmpeg_installed = False
|
||||
try:
|
||||
@@ -222,80 +240,93 @@ def initialize_modules(
|
||||
|
||||
# 初始化TTS模块
|
||||
if init_tts:
|
||||
select_tts_module = config["selected_module"]["TTS"]
|
||||
tts_type = (
|
||||
config["selected_module"]["TTS"]
|
||||
if "type" not in config["TTS"][config["selected_module"]["TTS"]]
|
||||
else config["TTS"][config["selected_module"]["TTS"]]["type"]
|
||||
select_tts_module
|
||||
if "type" not in config["TTS"][select_tts_module]
|
||||
else config["TTS"][select_tts_module]["type"]
|
||||
)
|
||||
modules["tts"] = tts.create_instance(
|
||||
tts_type,
|
||||
config["TTS"][config["selected_module"]["TTS"]],
|
||||
bool(config.get("delete_audio", True)),
|
||||
config["TTS"][select_tts_module],
|
||||
str(config.get("delete_audio", True)).lower() in ("true", "1", "yes"),
|
||||
)
|
||||
logger.bind(tag=TAG).info(f"初始化组件: tts成功")
|
||||
logger.bind(tag=TAG).info(f"初始化组件: tts成功 {select_tts_module}")
|
||||
|
||||
# 初始化LLM模块
|
||||
if init_llm:
|
||||
select_llm_module = config["selected_module"]["LLM"]
|
||||
llm_type = (
|
||||
config["selected_module"]["LLM"]
|
||||
if "type" not in config["LLM"][config["selected_module"]["LLM"]]
|
||||
else config["LLM"][config["selected_module"]["LLM"]]["type"]
|
||||
select_llm_module
|
||||
if "type" not in config["LLM"][select_llm_module]
|
||||
else config["LLM"][select_llm_module]["type"]
|
||||
)
|
||||
modules["llm"] = llm.create_instance(
|
||||
llm_type,
|
||||
config["LLM"][config["selected_module"]["LLM"]],
|
||||
config["LLM"][select_llm_module],
|
||||
)
|
||||
logger.bind(tag=TAG).info(f"初始化组件: llm成功")
|
||||
logger.bind(tag=TAG).info(f"初始化组件: llm成功 {select_llm_module}")
|
||||
|
||||
# 初始化Intent模块
|
||||
if init_intent:
|
||||
select_intent_module = config["selected_module"]["Intent"]
|
||||
intent_type = (
|
||||
config["selected_module"]["Intent"]
|
||||
if "type" not in config["Intent"][config["selected_module"]["Intent"]]
|
||||
else config["Intent"][config["selected_module"]["Intent"]]["type"]
|
||||
select_intent_module
|
||||
if "type" not in config["Intent"][select_intent_module]
|
||||
else config["Intent"][select_intent_module]["type"]
|
||||
)
|
||||
modules["intent"] = intent.create_instance(
|
||||
intent_type,
|
||||
config["Intent"][config["selected_module"]["Intent"]],
|
||||
config["Intent"][select_intent_module],
|
||||
)
|
||||
logger.bind(tag=TAG).info(f"初始化组件: intent成功")
|
||||
logger.bind(tag=TAG).info(f"初始化组件: intent成功 {select_intent_module}")
|
||||
|
||||
# 初始化Memory模块
|
||||
if init_memory:
|
||||
select_memory_module = config["selected_module"]["Memory"]
|
||||
memory_type = (
|
||||
config["selected_module"]["Memory"]
|
||||
if "type" not in config["Memory"][config["selected_module"]["Memory"]]
|
||||
else config["Memory"][config["selected_module"]["Memory"]]["type"]
|
||||
select_memory_module
|
||||
if "type" not in config["Memory"][select_memory_module]
|
||||
else config["Memory"][select_memory_module]["type"]
|
||||
)
|
||||
modules["memory"] = memory.create_instance(
|
||||
memory_type,
|
||||
config["Memory"][config["selected_module"]["Memory"]],
|
||||
config["Memory"][select_memory_module],
|
||||
)
|
||||
logger.bind(tag=TAG).info(f"初始化组件: memory成功")
|
||||
logger.bind(tag=TAG).info(f"初始化组件: memory成功 {select_memory_module}")
|
||||
|
||||
# 初始化VAD模块
|
||||
if init_vad:
|
||||
select_vad_module = config["selected_module"]["VAD"]
|
||||
vad_type = (
|
||||
config["selected_module"]["VAD"]
|
||||
if "type" not in config["VAD"][config["selected_module"]["VAD"]]
|
||||
else config["VAD"][config["selected_module"]["VAD"]]["type"]
|
||||
select_vad_module
|
||||
if "type" not in config["VAD"][select_vad_module]
|
||||
else config["VAD"][select_vad_module]["type"]
|
||||
)
|
||||
modules["vad"] = vad.create_instance(
|
||||
vad_type,
|
||||
config["VAD"][config["selected_module"]["VAD"]],
|
||||
config["VAD"][select_vad_module],
|
||||
)
|
||||
logger.bind(tag=TAG).info(f"初始化组件: vad成功")
|
||||
logger.bind(tag=TAG).info(f"初始化组件: vad成功 {select_vad_module}")
|
||||
|
||||
# 初始化ASR模块
|
||||
if init_asr:
|
||||
select_asr_module = config["selected_module"]["ASR"]
|
||||
asr_type = (
|
||||
config["selected_module"]["ASR"]
|
||||
if "type" not in config["ASR"][config["selected_module"]["ASR"]]
|
||||
else config["ASR"][config["selected_module"]["ASR"]]["type"]
|
||||
select_asr_module
|
||||
if "type" not in config["ASR"][select_asr_module]
|
||||
else config["ASR"][select_asr_module]["type"]
|
||||
)
|
||||
modules["asr"] = asr.create_instance(
|
||||
asr_type,
|
||||
config["ASR"][config["selected_module"]["ASR"]],
|
||||
bool(config.get("delete_audio", True)),
|
||||
config["ASR"][select_asr_module],
|
||||
str(config.get("delete_audio", True)).lower() in ("true", "1", "yes"),
|
||||
)
|
||||
logger.bind(tag=TAG).info(f"初始化组件: asr成功")
|
||||
logger.bind(tag=TAG).info(f"初始化组件: asr成功 {select_asr_module}")
|
||||
|
||||
# 初始化自定义prompt
|
||||
if config.get("prompt", None) is not None:
|
||||
modules["prompt"] = config["prompt"]
|
||||
logger.bind(tag=TAG).info(f"初始化组件: prompt成功 {modules['prompt'][:50]}...")
|
||||
|
||||
return modules
|
||||
|
||||
@@ -992,7 +992,9 @@ function connectToServer() {
|
||||
|
||||
if(connectButton.id === "connectButton") {
|
||||
connectButton.textContent = '断开';
|
||||
connectButton.onclick = disconnectFromServer;
|
||||
// connectButton.onclick = disconnectFromServer;
|
||||
connectButton.removeEventListener("click", connectToServer);
|
||||
connectButton.addEventListener("click", disconnectFromServer);
|
||||
}
|
||||
|
||||
if(messageInput.id === "messageInput") {
|
||||
@@ -1011,7 +1013,9 @@ function connectToServer() {
|
||||
|
||||
if(connectButton.id === "connectButton") {
|
||||
connectButton.textContent = '连接';
|
||||
connectButton.onclick = connectToServer;
|
||||
// connectButton.onclick = connectToServer;
|
||||
connectButton.removeEventListener("click", disconnectFromServer);
|
||||
connectButton.addEventListener("click", connectToServer);
|
||||
}
|
||||
|
||||
if(messageInput.id === "messageInput") {
|
||||
@@ -1175,7 +1179,7 @@ function updateStatus(message, type = 'info') {
|
||||
// 处理文本消息
|
||||
function handleTextMessage(message) {
|
||||
if (message.type === 'hello') {
|
||||
console.log(`服务器回应:${message.message}`);
|
||||
console.log(`服务器回应:${JSON.stringify(message, null, 2)}`);
|
||||
} else if (message.type === 'tts') {
|
||||
// TTS状态消息
|
||||
if (message.state === 'start') {
|
||||
|
||||
@@ -42,6 +42,50 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.section h2 .toggle-button {
|
||||
margin-left: auto;
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
height: 28px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.device-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin-left: 20px;
|
||||
padding: 0 15px;
|
||||
background-color: #f9f9f9;
|
||||
border-radius: 4px;
|
||||
height: 28px;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.device-info span {
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.device-info strong {
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.config-panel {
|
||||
display: none;
|
||||
transition: all 0.3s ease;
|
||||
margin-top: 5px;
|
||||
padding: 5px 0;
|
||||
}
|
||||
|
||||
.config-panel.expanded {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
@@ -70,7 +114,8 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
#serverUrl {
|
||||
#serverUrl,
|
||||
#otaUrl {
|
||||
flex-grow: 1;
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
@@ -316,6 +361,76 @@
|
||||
gap: 20px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.config-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.config-item label {
|
||||
width: 100px;
|
||||
text-align: right;
|
||||
margin-right: 10px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.config-item input {
|
||||
flex-grow: 1;
|
||||
padding: 6px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.connection-controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.connection-controls input {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.connection-controls button {
|
||||
white-space: nowrap;
|
||||
padding: 8px 15px;
|
||||
}
|
||||
|
||||
.connection-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin-left: 20px;
|
||||
padding: 0 15px;
|
||||
background-color: #f9f9f9;
|
||||
border-radius: 4px;
|
||||
height: 28px;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.connection-status span {
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.connection-status .status {
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
@@ -326,17 +441,56 @@
|
||||
<div id="scriptStatus" style="position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);">
|
||||
正在加载Opus库...</div>
|
||||
|
||||
<!-- 添加配置面板 -->
|
||||
<div class="section">
|
||||
<h2>WebSocket连接 <span id="connectionStatus" class="status">未连接</span></h2>
|
||||
<div class="control-panel">
|
||||
<input type="text" id="serverUrl" value="ws://127.0.0.1:8000/xiaozhi/v1/" placeholder="WebSocket服务器地址">
|
||||
<h2>
|
||||
设备配置
|
||||
<span class="device-info">
|
||||
<span>MAC: <strong id="displayMac">00:11:22:33:44:55</strong></span>
|
||||
<span>客户端: <strong id="displayClient">web_test_client</strong></span>
|
||||
</span>
|
||||
<button class="toggle-button" id="toggleConfig">编辑</button>
|
||||
</h2>
|
||||
<div class="config-panel" id="configPanel">
|
||||
<div class="control-panel">
|
||||
<div class="config-item">
|
||||
<label for="deviceMac">设备MAC:</label>
|
||||
<input type="text" id="deviceMac" value="00:11:22:33:44:55" placeholder="设备MAC地址">
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<label for="deviceName">设备名称:</label>
|
||||
<input type="text" id="deviceName" value="Web测试设备" placeholder="设备名称">
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<label for="clientId">客户端ID:</label>
|
||||
<input type="text" id="clientId" value="web_test_client" placeholder="客户端ID">
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<label for="token">认证Token:</label>
|
||||
<input type="text" id="token" value="your-token1" placeholder="认证Token">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>
|
||||
连接信息
|
||||
<span class="connection-status">
|
||||
<span>OTA: <span id="otaStatus" class="status">ota未连接</span></span>
|
||||
<span>WS: <span id="connectionStatus" class="status">ws未连接</span></span>
|
||||
</span>
|
||||
</h2>
|
||||
<div class="connection-controls">
|
||||
<input type="text" id="otaUrl" value="http://127.0.0.1:8002/xiaozhi/ota/" placeholder="OTA服务器地址" />
|
||||
<input type="text" id="serverUrl" value="ws://127.0.0.1:8000/xiaozhi/v1/"
|
||||
placeholder="WebSocket服务器地址" />
|
||||
<button id="connectButton">连接</button>
|
||||
<button id="authTestButton">测试认证</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>消息发送</h2>
|
||||
<div class="tabs">
|
||||
<button class="tab active" data-tab="text">文本消息</button>
|
||||
<button class="tab" data-tab="voice">语音消息</button>
|
||||
@@ -475,22 +629,36 @@
|
||||
|
||||
// 日志函数
|
||||
function log(message, type = 'info') {
|
||||
const logEntry = document.createElement('div');
|
||||
logEntry.className = `log-entry log-${type}`;
|
||||
logEntry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
|
||||
if (type === 'error') {
|
||||
logEntry.style.color = 'red';
|
||||
} else if (type === 'debug') {
|
||||
logEntry.style.color = 'gray';
|
||||
return;
|
||||
} else if (type === 'warning') {
|
||||
logEntry.style.color = 'orange';
|
||||
} else if (type === 'success') {
|
||||
logEntry.style.color = 'green';
|
||||
} else {
|
||||
logEntry.style.color = 'black';
|
||||
}
|
||||
logContainer.appendChild(logEntry);
|
||||
// 将消息按换行符分割成多行
|
||||
const lines = message.split('\n');
|
||||
const now = new Date();
|
||||
// const timestamp = `[${now.toLocaleTimeString()}] `;
|
||||
const timestamp = `[${now.toLocaleTimeString()}.${now.getMilliseconds().toString().padStart(3, '0')}] `;
|
||||
// 为每一行创建日志条目
|
||||
lines.forEach((line, index) => {
|
||||
const logEntry = document.createElement('div');
|
||||
logEntry.className = `log-entry log-${type}`;
|
||||
// 如果是第一条日志,显示时间戳
|
||||
const prefix = index === 0 ? timestamp : ' '.repeat(timestamp.length);
|
||||
logEntry.textContent = `${prefix}${line}`;
|
||||
// logEntry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
|
||||
// logEntry.style 保留起始的空格
|
||||
logEntry.style.whiteSpace = 'pre';
|
||||
if (type === 'error') {
|
||||
logEntry.style.color = 'red';
|
||||
} else if (type === 'debug') {
|
||||
logEntry.style.color = 'gray';
|
||||
return;
|
||||
} else if (type === 'warning') {
|
||||
logEntry.style.color = 'orange';
|
||||
} else if (type === 'success') {
|
||||
logEntry.style.color = 'green';
|
||||
} else {
|
||||
logEntry.style.color = 'black';
|
||||
}
|
||||
logContainer.appendChild(logEntry);
|
||||
});
|
||||
|
||||
logContainer.scrollTop = logContainer.scrollHeight;
|
||||
}
|
||||
|
||||
@@ -540,16 +708,16 @@
|
||||
// 开始音频缓冲过程
|
||||
function startAudioBuffering() {
|
||||
if (isAudioBuffering || isAudioPlaying) return;
|
||||
|
||||
|
||||
isAudioBuffering = true;
|
||||
log("开始音频缓冲...", 'info');
|
||||
|
||||
|
||||
// 先尝试初始化解码器,以便在播放时已准备好
|
||||
initOpusDecoder().catch(error => {
|
||||
log(`预初始化Opus解码器失败: ${error.message}`, 'warning');
|
||||
// 继续缓冲,我们会在播放时再次尝试初始化
|
||||
});
|
||||
|
||||
|
||||
// 设置超时,如果在一定时间内没有收集到足够的音频包,就开始播放
|
||||
setTimeout(() => {
|
||||
if (isAudioBuffering && audioBufferQueue.length > 0) {
|
||||
@@ -557,14 +725,14 @@
|
||||
playBufferedAudio();
|
||||
}
|
||||
}, 300); // 300ms超时
|
||||
|
||||
|
||||
// 监控缓冲进度
|
||||
const bufferCheckInterval = setInterval(() => {
|
||||
if (!isAudioBuffering) {
|
||||
clearInterval(bufferCheckInterval);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// 当累积了足够的音频包,开始播放
|
||||
if (audioBufferQueue.length >= BUFFER_THRESHOLD) {
|
||||
clearInterval(bufferCheckInterval);
|
||||
@@ -577,10 +745,10 @@
|
||||
// 播放已缓冲的音频
|
||||
function playBufferedAudio() {
|
||||
if (isAudioPlaying || audioBufferQueue.length === 0) return;
|
||||
|
||||
|
||||
isAudioPlaying = true;
|
||||
isAudioBuffering = false;
|
||||
|
||||
|
||||
// 确保Opus解码器已初始化
|
||||
const initDecoderAndPlay = async () => {
|
||||
try {
|
||||
@@ -591,7 +759,7 @@
|
||||
});
|
||||
log('创建音频上下文,采样率: ' + SAMPLE_RATE + 'Hz', 'debug');
|
||||
}
|
||||
|
||||
|
||||
// 确保解码器已初始化
|
||||
if (!opusDecoder) {
|
||||
log('初始化Opus解码器...', 'info');
|
||||
@@ -607,7 +775,7 @@
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 创建流式播放上下文
|
||||
if (!streamingContext) {
|
||||
streamingContext = {
|
||||
@@ -617,16 +785,16 @@
|
||||
source: null, // 当前音频源
|
||||
totalSamples: 0, // 累积的总样本数
|
||||
lastPlayTime: 0, // 上次播放的时间戳
|
||||
|
||||
|
||||
// 将Opus数据解码为PCM
|
||||
decodeOpusFrames: async function(opusFrames) {
|
||||
decodeOpusFrames: async function (opusFrames) {
|
||||
if (!opusDecoder) {
|
||||
log('Opus解码器未初始化,无法解码', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
let decodedSamples = [];
|
||||
|
||||
|
||||
for (const frame of opusFrames) {
|
||||
try {
|
||||
// 使用Opus解码器解码
|
||||
@@ -640,12 +808,12 @@
|
||||
log("Opus解码失败: " + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (decodedSamples.length > 0) {
|
||||
// 添加到解码队列
|
||||
this.queue.push(...decodedSamples);
|
||||
this.totalSamples += decodedSamples.length;
|
||||
|
||||
|
||||
// 如果累积了至少0.2秒的音频,开始播放
|
||||
const minSamples = SAMPLE_RATE * MIN_AUDIO_DURATION;
|
||||
if (!this.playing && this.queue.length >= minSamples) {
|
||||
@@ -655,50 +823,50 @@
|
||||
log('没有成功解码的样本', 'warning');
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
// 开始播放音频
|
||||
startPlaying: function() {
|
||||
startPlaying: function () {
|
||||
if (this.playing || this.queue.length === 0) return;
|
||||
|
||||
|
||||
this.playing = true;
|
||||
|
||||
|
||||
// 创建新的音频缓冲区
|
||||
const minPlaySamples = Math.min(this.queue.length, SAMPLE_RATE); // 最多播放1秒
|
||||
const currentSamples = this.queue.splice(0, minPlaySamples);
|
||||
|
||||
|
||||
const audioBuffer = audioContext.createBuffer(CHANNELS, currentSamples.length, SAMPLE_RATE);
|
||||
audioBuffer.copyToChannel(new Float32Array(currentSamples), 0);
|
||||
|
||||
|
||||
// 创建音频源
|
||||
this.source = audioContext.createBufferSource();
|
||||
this.source.buffer = audioBuffer;
|
||||
|
||||
|
||||
// 创建增益节点用于平滑过渡
|
||||
const gainNode = audioContext.createGain();
|
||||
|
||||
|
||||
// 应用淡入淡出效果避免爆音
|
||||
const fadeDuration = 0.02; // 20毫秒
|
||||
gainNode.gain.setValueAtTime(0, audioContext.currentTime);
|
||||
gainNode.gain.linearRampToValueAtTime(1, audioContext.currentTime + fadeDuration);
|
||||
|
||||
|
||||
const duration = audioBuffer.duration;
|
||||
if (duration > fadeDuration * 2) {
|
||||
gainNode.gain.setValueAtTime(1, audioContext.currentTime + duration - fadeDuration);
|
||||
gainNode.gain.linearRampToValueAtTime(0, audioContext.currentTime + duration);
|
||||
}
|
||||
|
||||
|
||||
// 连接节点并开始播放
|
||||
this.source.connect(gainNode);
|
||||
gainNode.connect(audioContext.destination);
|
||||
|
||||
|
||||
this.lastPlayTime = audioContext.currentTime;
|
||||
log(`开始播放 ${currentSamples.length} 个样本,约 ${(currentSamples.length / SAMPLE_RATE).toFixed(2)} 秒`, 'info');
|
||||
|
||||
|
||||
// 播放结束后的处理
|
||||
this.source.onended = () => {
|
||||
this.source = null;
|
||||
this.playing = false;
|
||||
|
||||
|
||||
// 如果队列中还有数据或者缓冲区有新数据,继续播放
|
||||
if (this.queue.length > 0) {
|
||||
setTimeout(() => this.startPlaying(), 10);
|
||||
@@ -729,26 +897,26 @@
|
||||
}, 500); // 500ms超时
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
this.source.start();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// 开始处理缓冲的数据
|
||||
const frames = [...audioBufferQueue];
|
||||
audioBufferQueue = []; // 清空缓冲队列
|
||||
|
||||
|
||||
// 解码并播放
|
||||
await streamingContext.decodeOpusFrames(frames);
|
||||
|
||||
|
||||
} catch (error) {
|
||||
log(`播放已缓冲的音频出错: ${error.message}`, 'error');
|
||||
isAudioPlaying = false;
|
||||
streamingContext = null;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// 执行初始化和播放
|
||||
initDecoderAndPlay();
|
||||
}
|
||||
@@ -766,7 +934,7 @@
|
||||
// 初始化Opus解码器 - 确保完全初始化完成后才返回
|
||||
async function initOpusDecoder() {
|
||||
if (opusDecoder) return opusDecoder; // 已经初始化
|
||||
|
||||
|
||||
try {
|
||||
// 检查ModuleInstance是否存在
|
||||
if (typeof window.ModuleInstance === 'undefined') {
|
||||
@@ -778,9 +946,9 @@
|
||||
throw new Error('Opus库未加载,ModuleInstance和Module对象都不存在');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const mod = window.ModuleInstance;
|
||||
|
||||
|
||||
// 创建解码器对象
|
||||
opusDecoder = {
|
||||
channels: CHANNELS,
|
||||
@@ -788,55 +956,55 @@
|
||||
frameSize: FRAME_SIZE,
|
||||
module: mod,
|
||||
decoderPtr: null, // 初始为null
|
||||
|
||||
|
||||
// 初始化解码器
|
||||
init: function() {
|
||||
init: function () {
|
||||
if (this.decoderPtr) return true; // 已经初始化
|
||||
|
||||
|
||||
// 获取解码器大小
|
||||
const decoderSize = mod._opus_decoder_get_size(this.channels);
|
||||
log(`Opus解码器大小: ${decoderSize}字节`, 'debug');
|
||||
|
||||
|
||||
// 分配内存
|
||||
this.decoderPtr = mod._malloc(decoderSize);
|
||||
if (!this.decoderPtr) {
|
||||
throw new Error("无法分配解码器内存");
|
||||
}
|
||||
|
||||
|
||||
// 初始化解码器
|
||||
const err = mod._opus_decoder_init(
|
||||
this.decoderPtr,
|
||||
this.rate,
|
||||
this.channels
|
||||
);
|
||||
|
||||
|
||||
if (err < 0) {
|
||||
this.destroy(); // 清理资源
|
||||
throw new Error(`Opus解码器初始化失败: ${err}`);
|
||||
}
|
||||
|
||||
|
||||
log("Opus解码器初始化成功", 'success');
|
||||
return true;
|
||||
},
|
||||
|
||||
|
||||
// 解码方法
|
||||
decode: function(opusData) {
|
||||
decode: function (opusData) {
|
||||
if (!this.decoderPtr) {
|
||||
if (!this.init()) {
|
||||
throw new Error("解码器未初始化且无法初始化");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const mod = this.module;
|
||||
|
||||
|
||||
// 为Opus数据分配内存
|
||||
const opusPtr = mod._malloc(opusData.length);
|
||||
mod.HEAPU8.set(opusData, opusPtr);
|
||||
|
||||
|
||||
// 为PCM输出分配内存
|
||||
const pcmPtr = mod._malloc(this.frameSize * 2); // Int16 = 2字节
|
||||
|
||||
|
||||
// 解码
|
||||
const decodedSamples = mod._opus_decode(
|
||||
this.decoderPtr,
|
||||
@@ -846,46 +1014,46 @@
|
||||
this.frameSize,
|
||||
0 // 不使用FEC
|
||||
);
|
||||
|
||||
|
||||
if (decodedSamples < 0) {
|
||||
mod._free(opusPtr);
|
||||
mod._free(pcmPtr);
|
||||
throw new Error(`Opus解码失败: ${decodedSamples}`);
|
||||
}
|
||||
|
||||
|
||||
// 复制解码后的数据
|
||||
const decodedData = new Int16Array(decodedSamples);
|
||||
for (let i = 0; i < decodedSamples; i++) {
|
||||
decodedData[i] = mod.HEAP16[(pcmPtr >> 1) + i];
|
||||
}
|
||||
|
||||
|
||||
// 释放内存
|
||||
mod._free(opusPtr);
|
||||
mod._free(pcmPtr);
|
||||
|
||||
|
||||
return decodedData;
|
||||
} catch (error) {
|
||||
log(`Opus解码错误: ${error.message}`, 'error');
|
||||
return new Int16Array(0);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
// 销毁方法
|
||||
destroy: function() {
|
||||
destroy: function () {
|
||||
if (this.decoderPtr) {
|
||||
this.module._free(this.decoderPtr);
|
||||
this.decoderPtr = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// 初始化解码器
|
||||
if (!opusDecoder.init()) {
|
||||
throw new Error("Opus解码器初始化失败");
|
||||
}
|
||||
|
||||
|
||||
return opusDecoder;
|
||||
|
||||
|
||||
} catch (error) {
|
||||
log(`Opus解码器初始化失败: ${error.message}`, 'error');
|
||||
opusDecoder = null; // 重置为null,以便下次重试
|
||||
@@ -1118,25 +1286,100 @@
|
||||
}
|
||||
|
||||
// 连接WebSocket服务器
|
||||
function connectToServer() {
|
||||
async function connectToServer() {
|
||||
const url = serverUrlInput.value.trim();
|
||||
if (url === '') return;
|
||||
|
||||
try {
|
||||
// 获取并验证配置
|
||||
const config = getConfig();
|
||||
if (!validateConfig(config)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查URL格式
|
||||
if (!url.startsWith('ws://') && !url.startsWith('wss://')) {
|
||||
log('URL格式错误,必须以ws://或wss://开头', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// 先检查OTA状态
|
||||
log('正在检查OTA状态...', 'info');
|
||||
const otaUrl = document.getElementById('otaUrl').value.trim();
|
||||
|
||||
try {
|
||||
const otaResponse = await fetch(otaUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Device-Id': config.deviceId,
|
||||
'Client-Id': config.clientId
|
||||
},
|
||||
body: JSON.stringify({
|
||||
"version": 0,
|
||||
"uuid": "",
|
||||
"application": {
|
||||
"name": "xiaozhi-web-test",
|
||||
"version": "1.0.0",
|
||||
"compile_time": "2025-04-16 10:00:00",
|
||||
"idf_version": "4.4.3",
|
||||
"elf_sha256": "1234567890abcdef1234567890abcdef1234567890abcdef"
|
||||
},
|
||||
"ota": {
|
||||
"label": "xiaozhi-web-test",
|
||||
},
|
||||
"board": {
|
||||
"type": "xiaozhi-web-test",
|
||||
"ssid": "xiaozhi-web-test",
|
||||
"rssi": 0,
|
||||
"channel": 0,
|
||||
"ip": "192.168.1.1",
|
||||
"mac": config.deviceMac
|
||||
},
|
||||
"flash_size": 0,
|
||||
"minimum_free_heap_size": 0,
|
||||
"mac_address": config.deviceMac,
|
||||
"chip_model_name": "",
|
||||
"chip_info": {
|
||||
"model": 0,
|
||||
"cores": 0,
|
||||
"revision": 0,
|
||||
"features": 0
|
||||
},
|
||||
"partition_table": [
|
||||
{
|
||||
"label": "",
|
||||
"type": 0,
|
||||
"subtype": 0,
|
||||
"address": 0,
|
||||
"size": 0
|
||||
}
|
||||
]
|
||||
})
|
||||
});
|
||||
|
||||
if (!otaResponse.ok) {
|
||||
throw new Error(`OTA检查失败: ${otaResponse.status} ${otaResponse.statusText}`);
|
||||
}
|
||||
|
||||
const otaResult = await otaResponse.json();
|
||||
log(`OTA检查结果: ${JSON.stringify(otaResult)}`, 'info');
|
||||
|
||||
log('OTA检查通过,开始连接WebSocket...', 'success');
|
||||
document.getElementById('otaStatus').textContent = 'ota已连接';
|
||||
document.getElementById('otaStatus').style.color = 'green';
|
||||
} catch (error) {
|
||||
log(`OTA检查错误: ${error.message}`, 'error');
|
||||
document.getElementById('otaStatus').textContent = 'ota未连接';
|
||||
document.getElementById('otaStatus').style.color = 'red';
|
||||
}
|
||||
|
||||
// 使用自定义WebSocket实现以添加认证头信息
|
||||
// 注意:浏览器原生WebSocket不支持自定义头,需要通过服务器添加一个代理层
|
||||
// 但我们可以通过URL参数模拟认证信息
|
||||
let connUrl = new URL(url);
|
||||
|
||||
// 添加认证参数
|
||||
connUrl.searchParams.append('device_id', 'web_test_device');
|
||||
connUrl.searchParams.append('device_mac', '00:11:22:33:44:55');
|
||||
connUrl.searchParams.append('device-id', config.deviceId);
|
||||
connUrl.searchParams.append('client-id', config.clientId);
|
||||
|
||||
log(`正在连接: ${connUrl.toString()}`, 'info');
|
||||
websocket = new WebSocket(connUrl.toString());
|
||||
@@ -1146,14 +1389,16 @@
|
||||
|
||||
websocket.onopen = async () => {
|
||||
log(`已连接到服务器: ${url}`, 'success');
|
||||
connectionStatus.textContent = '已连接';
|
||||
connectionStatus.textContent = 'ws已连接';
|
||||
connectionStatus.style.color = 'green';
|
||||
|
||||
// 连接成功后发送hello消息
|
||||
await sendHelloMessage();
|
||||
|
||||
connectButton.textContent = '断开';
|
||||
connectButton.onclick = disconnectFromServer;
|
||||
connectButton.removeEventListener('click', connectToServer);
|
||||
connectButton.addEventListener('click', disconnectFromServer);
|
||||
// connectButton.onclick = disconnectFromServer;
|
||||
messageInput.disabled = false;
|
||||
sendTextButton.disabled = false;
|
||||
|
||||
@@ -1165,11 +1410,13 @@
|
||||
|
||||
websocket.onclose = () => {
|
||||
log('已断开连接', 'info');
|
||||
connectionStatus.textContent = '已断开';
|
||||
connectionStatus.textContent = 'ws已断开';
|
||||
connectionStatus.style.color = 'red';
|
||||
|
||||
connectButton.textContent = '连接';
|
||||
connectButton.onclick = connectToServer;
|
||||
connectButton.removeEventListener('click', disconnectFromServer);
|
||||
connectButton.addEventListener('click', connectToServer);
|
||||
// connectButton.onclick = connectToServer;
|
||||
messageInput.disabled = true;
|
||||
sendTextButton.disabled = true;
|
||||
recordButton.disabled = true;
|
||||
@@ -1178,7 +1425,7 @@
|
||||
|
||||
websocket.onerror = (error) => {
|
||||
log(`WebSocket错误: ${error.message || '未知错误'}`, 'error');
|
||||
connectionStatus.textContent = '连接错误';
|
||||
connectionStatus.textContent = 'ws未连接';
|
||||
connectionStatus.style.color = 'red';
|
||||
};
|
||||
|
||||
@@ -1189,7 +1436,7 @@
|
||||
const message = JSON.parse(event.data);
|
||||
|
||||
if (message.type === 'hello') {
|
||||
log(`服务器回应:${message.message}`, 'info');
|
||||
log(`服务器回应:${JSON.stringify(message, null, 2)}`, 'success');
|
||||
} else if (message.type === 'tts') {
|
||||
// TTS状态消息
|
||||
if (message.state === 'start') {
|
||||
@@ -1244,11 +1491,11 @@
|
||||
}
|
||||
};
|
||||
|
||||
connectionStatus.textContent = '正在连接...';
|
||||
connectionStatus.textContent = 'ws未连接';
|
||||
connectionStatus.style.color = 'orange';
|
||||
} catch (error) {
|
||||
log(`连接错误: ${error.message}`, 'error');
|
||||
connectionStatus.textContent = '连接失败';
|
||||
connectionStatus.textContent = 'ws未连接';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1257,13 +1504,15 @@
|
||||
if (!websocket || websocket.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
try {
|
||||
const config = getConfig();
|
||||
|
||||
// 设置设备信息
|
||||
const helloMessage = {
|
||||
type: 'hello',
|
||||
device_id: 'web_test_device',
|
||||
device_name: 'Web测试设备',
|
||||
device_mac: '00:11:22:33:44:55',
|
||||
token: 'your-token1' // 使用config.yaml中配置的token
|
||||
device_id: config.deviceId,
|
||||
device_name: config.deviceName,
|
||||
device_mac: config.deviceMac,
|
||||
token: config.token
|
||||
};
|
||||
|
||||
log('发送hello握手消息', 'info');
|
||||
@@ -1338,6 +1587,34 @@
|
||||
connectButton.addEventListener('click', connectToServer);
|
||||
document.getElementById('authTestButton').addEventListener('click', testAuthentication);
|
||||
|
||||
// 设备配置面板折叠/展开
|
||||
const toggleButton = document.getElementById('toggleConfig');
|
||||
const configPanel = document.getElementById('configPanel');
|
||||
const deviceMacInput = document.getElementById('deviceMac');
|
||||
const clientIdInput = document.getElementById('clientId');
|
||||
const displayMac = document.getElementById('displayMac');
|
||||
const displayClient = document.getElementById('displayClient');
|
||||
|
||||
// 更新显示的值
|
||||
function updateDisplayValues() {
|
||||
displayMac.textContent = deviceMacInput.value;
|
||||
displayClient.textContent = clientIdInput.value;
|
||||
}
|
||||
|
||||
// 监听输入变化
|
||||
deviceMacInput.addEventListener('input', updateDisplayValues);
|
||||
clientIdInput.addEventListener('input', updateDisplayValues);
|
||||
|
||||
// 初始更新显示值
|
||||
updateDisplayValues();
|
||||
|
||||
// 切换面板显示
|
||||
toggleButton.addEventListener('click', () => {
|
||||
const isExpanded = configPanel.classList.contains('expanded');
|
||||
configPanel.classList.toggle('expanded');
|
||||
toggleButton.textContent = isExpanded ? '编辑' : '收起';
|
||||
});
|
||||
|
||||
// 标签页切换
|
||||
const tabs = document.querySelectorAll('.tab');
|
||||
tabs.forEach(tab => {
|
||||
@@ -1372,12 +1649,14 @@
|
||||
async function testAuthentication() {
|
||||
log('开始测试认证...', 'info');
|
||||
|
||||
const config = getConfig();
|
||||
|
||||
// 显示服务器配置
|
||||
log('-------- 服务器认证配置检查 --------', 'info');
|
||||
log('请确认config.yaml中的auth配置:', 'info');
|
||||
log('1. server.auth.enabled 为 false 或服务器已正确配置认证', 'info');
|
||||
log('2. 如果启用了认证,请确认使用了正确的token', 'info');
|
||||
log('3. 或者在allowed_devices中添加了测试设备MAC:00:11:22:33:44:55', 'info');
|
||||
log(`3. 或者在allowed_devices中添加了测试设备MAC:${config.deviceMac}`, 'info');
|
||||
|
||||
const serverUrl = serverUrlInput.value.trim();
|
||||
if (!serverUrl) {
|
||||
@@ -1418,9 +1697,9 @@
|
||||
log('测试2: 尝试带token参数连接...', 'info');
|
||||
|
||||
let url = new URL(serverUrl);
|
||||
url.searchParams.append('token', 'your-token1');
|
||||
url.searchParams.append('device_id', 'web_test_device');
|
||||
url.searchParams.append('device_mac', '00:11:22:33:44:55');
|
||||
url.searchParams.append('token', config.token);
|
||||
url.searchParams.append('device_id', config.deviceId);
|
||||
url.searchParams.append('device_mac', config.deviceMac);
|
||||
|
||||
const ws2 = new WebSocket(url.toString());
|
||||
|
||||
@@ -1430,9 +1709,9 @@
|
||||
// 尝试发送hello消息
|
||||
const helloMsg = {
|
||||
type: 'hello',
|
||||
device_id: 'web_test_device',
|
||||
device_mac: '00:11:22:33:44:55',
|
||||
token: 'your-token1'
|
||||
device_id: config.deviceId,
|
||||
device_mac: config.deviceMac,
|
||||
token: config.token
|
||||
};
|
||||
|
||||
ws2.send(JSON.stringify(helloMsg));
|
||||
@@ -2074,19 +2353,19 @@
|
||||
if (opusData.length > 0) {
|
||||
// 将数据添加到缓冲队列
|
||||
audioBufferQueue.push(opusData);
|
||||
|
||||
|
||||
// 如果收到的是第一个音频包,开始缓冲过程
|
||||
if (audioBufferQueue.length === 1 && !isAudioBuffering && !isAudioPlaying) {
|
||||
startAudioBuffering();
|
||||
}
|
||||
} else {
|
||||
log('收到空音频数据帧,可能是结束标志', 'warning');
|
||||
|
||||
|
||||
// 如果缓冲队列中有数据且没有在播放,立即开始播放
|
||||
if (audioBufferQueue.length > 0 && !isAudioPlaying) {
|
||||
playBufferedAudio();
|
||||
}
|
||||
|
||||
|
||||
// 如果正在播放,发送结束信号
|
||||
if (isAudioPlaying && streamingContext) {
|
||||
streamingContext.endOfStream = true;
|
||||
@@ -2097,6 +2376,31 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 获取配置值
|
||||
function getConfig() {
|
||||
const deviceMac = document.getElementById('deviceMac').value.trim();
|
||||
return {
|
||||
deviceId: deviceMac, // 使用MAC地址作为deviceId
|
||||
deviceName: document.getElementById('deviceName').value.trim(),
|
||||
deviceMac: deviceMac,
|
||||
clientId: document.getElementById('clientId').value.trim(),
|
||||
token: document.getElementById('token').value.trim()
|
||||
};
|
||||
}
|
||||
|
||||
// 验证配置
|
||||
function validateConfig(config) {
|
||||
if (!config.deviceMac) {
|
||||
log('设备MAC地址不能为空', 'error');
|
||||
return false;
|
||||
}
|
||||
if (!config.clientId) {
|
||||
log('客户端ID不能为空', 'error');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
initApp();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user