mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-26 17:13:54 +08:00
454 lines
16 KiB
Python
454 lines
16 KiB
Python
import asyncio
|
|
import io
|
|
import os
|
|
import subprocess
|
|
import threading
|
|
import traceback
|
|
import uuid
|
|
import json
|
|
import base64
|
|
import requests
|
|
from datetime import datetime
|
|
|
|
import websockets
|
|
|
|
from config.logger import setup_logging
|
|
from core.providers.tts.dto.dto import TTSMessageDTO, MsgType, SentenceType
|
|
from core.utils.util import check_model_key
|
|
from core.providers.tts.base import TTSProviderBase
|
|
|
|
TAG = __name__
|
|
logger = setup_logging()
|
|
|
|
PROTOCOL_VERSION = 0b0001
|
|
DEFAULT_HEADER_SIZE = 0b0001
|
|
|
|
# Message Type:
|
|
FULL_CLIENT_REQUEST = 0b0001
|
|
AUDIO_ONLY_RESPONSE = 0b1011
|
|
FULL_SERVER_RESPONSE = 0b1001
|
|
ERROR_INFORMATION = 0b1111
|
|
|
|
# Message Type Specific Flags
|
|
MsgTypeFlagNoSeq = 0b0000 # Non-terminal packet with no sequence
|
|
MsgTypeFlagPositiveSeq = 0b1 # Non-terminal packet with sequence > 0
|
|
MsgTypeFlagLastNoSeq = 0b10 # last packet with no sequence
|
|
MsgTypeFlagNegativeSeq = 0b11 # Payload contains event number (int32)
|
|
MsgTypeFlagWithEvent = 0b100
|
|
# Message Serialization
|
|
NO_SERIALIZATION = 0b0000
|
|
JSON = 0b0001
|
|
# Message Compression
|
|
COMPRESSION_NO = 0b0000
|
|
COMPRESSION_GZIP = 0b0001
|
|
|
|
EVENT_NONE = 0
|
|
EVENT_Start_Connection = 1
|
|
|
|
EVENT_FinishConnection = 2
|
|
|
|
EVENT_ConnectionStarted = 50 # 成功建连
|
|
|
|
EVENT_ConnectionFailed = 51 # 建连失败(可能是无法通过权限认证)
|
|
|
|
EVENT_ConnectionFinished = 52 # 连接结束
|
|
|
|
# 上行Session事件
|
|
EVENT_StartSession = 100
|
|
|
|
EVENT_FinishSession = 102
|
|
# 下行Session事件
|
|
EVENT_SessionStarted = 150
|
|
EVENT_SessionFinished = 152
|
|
|
|
EVENT_SessionFailed = 153
|
|
|
|
# 上行通用事件
|
|
EVENT_TaskRequest = 200
|
|
|
|
# 下行TTS事件
|
|
EVENT_TTSSentenceStart = 350
|
|
|
|
EVENT_TTSSentenceEnd = 351
|
|
|
|
EVENT_TTSResponse = 352
|
|
|
|
|
|
class Header:
|
|
def __init__(
|
|
self,
|
|
protocol_version=PROTOCOL_VERSION,
|
|
header_size=DEFAULT_HEADER_SIZE,
|
|
message_type: int = 0,
|
|
message_type_specific_flags: int = 0,
|
|
serial_method: int = NO_SERIALIZATION,
|
|
compression_type: int = COMPRESSION_NO,
|
|
reserved_data=0,
|
|
):
|
|
self.header_size = header_size
|
|
self.protocol_version = protocol_version
|
|
self.message_type = message_type
|
|
self.message_type_specific_flags = message_type_specific_flags
|
|
self.serial_method = serial_method
|
|
self.compression_type = compression_type
|
|
self.reserved_data = reserved_data
|
|
|
|
def as_bytes(self) -> bytes:
|
|
return bytes(
|
|
[
|
|
(self.protocol_version << 4) | self.header_size,
|
|
(self.message_type << 4) | self.message_type_specific_flags,
|
|
(self.serial_method << 4) | self.compression_type,
|
|
self.reserved_data,
|
|
]
|
|
)
|
|
|
|
|
|
class Optional:
|
|
def __init__(
|
|
self, event: int = EVENT_NONE, sessionId: str = None, sequence: int = None
|
|
):
|
|
self.event = event
|
|
self.sessionId = sessionId
|
|
self.errorCode: int = 0
|
|
self.connectionId: str | None = None
|
|
self.response_meta_json: str | None = None
|
|
self.sequence = sequence
|
|
|
|
# 转成 byte 序列
|
|
def as_bytes(self) -> bytes:
|
|
option_bytes = bytearray()
|
|
if self.event != EVENT_NONE:
|
|
option_bytes.extend(self.event.to_bytes(4, "big", signed=True))
|
|
if self.sessionId is not None:
|
|
session_id_bytes = str.encode(self.sessionId)
|
|
size = len(session_id_bytes).to_bytes(4, "big", signed=True)
|
|
option_bytes.extend(size)
|
|
option_bytes.extend(session_id_bytes)
|
|
if self.sequence is not None:
|
|
option_bytes.extend(self.sequence.to_bytes(4, "big", signed=True))
|
|
return option_bytes
|
|
|
|
|
|
class Response:
|
|
def __init__(self, header: Header, optional: Optional):
|
|
self.optional = optional
|
|
self.header = header
|
|
self.payload: bytes | None = None
|
|
|
|
def __str__(self):
|
|
return super().__str__()
|
|
|
|
|
|
class TTSProvider(TTSProviderBase):
|
|
def __init__(self, config, delete_audio_file):
|
|
super().__init__(config, delete_audio_file)
|
|
self.appId = config.get("appid")
|
|
self.access_token = config.get("access_token")
|
|
self.cluster = config.get("cluster")
|
|
self.resource_id = config.get("resource_id")
|
|
self.voice = config.get("voice")
|
|
self.ws_url = config.get("ws_url")
|
|
self.authorization = config.get("authorization")
|
|
self.speaker = config.get("speaker")
|
|
self.header = {"Authorization": f"{self.authorization}{self.access_token}"}
|
|
self.stop_event_response = threading.Event()
|
|
self.enable_two_way = True
|
|
self.start_connection_flag = False
|
|
self.tts_text = ""
|
|
|
|
async def open_audio_channels(self):
|
|
await super().open_audio_channels()
|
|
ws_header = {
|
|
"X-Api-App-Key": self.appId,
|
|
"X-Api-Access-Key": self.access_token,
|
|
"X-Api-Resource-Id": self.resource_id,
|
|
"X-Api-Connect-Id": uuid.uuid4(),
|
|
}
|
|
self.ws = await websockets.connect(
|
|
self.ws_url, additional_headers=ws_header, max_size=1000000000
|
|
)
|
|
tts_priority = threading.Thread(
|
|
target=self._start_monitor_tts_response_thread(), daemon=True
|
|
)
|
|
tts_priority.start()
|
|
|
|
def generate_filename(self, extension=".wav"):
|
|
return os.path.join(
|
|
self.output_file,
|
|
f"tts-{datetime.now().date()}@{uuid.uuid4().hex}{extension}",
|
|
)
|
|
|
|
async def send_event(
|
|
self, header: bytes, optional: bytes | None = None, payload: bytes = None
|
|
):
|
|
full_client_request = bytearray(header)
|
|
if optional is not None:
|
|
full_client_request.extend(optional)
|
|
if payload is not None:
|
|
payload_size = len(payload).to_bytes(4, "big", signed=True)
|
|
full_client_request.extend(payload_size)
|
|
full_client_request.extend(payload)
|
|
await self.ws.send(full_client_request)
|
|
|
|
async def send_text(self, speaker: str, text: str, session_id):
|
|
header = Header(
|
|
message_type=FULL_CLIENT_REQUEST,
|
|
message_type_specific_flags=MsgTypeFlagWithEvent,
|
|
serial_method=JSON,
|
|
).as_bytes()
|
|
optional = Optional(event=EVENT_TaskRequest, sessionId=session_id).as_bytes()
|
|
payload = self.get_payload_bytes(
|
|
event=EVENT_TaskRequest, text=text, speaker=speaker
|
|
)
|
|
return await self.send_event(header, optional, payload)
|
|
|
|
# 读取 res 数组某段 字符串内容
|
|
def read_res_content(self, res: bytes, offset: int):
|
|
content_size = int.from_bytes(res[offset : offset + 4], "big", signed=True)
|
|
offset += 4
|
|
content = str(res[offset : offset + content_size])
|
|
offset += content_size
|
|
return content, offset
|
|
|
|
# 读取 payload
|
|
def read_res_payload(self, res: bytes, offset: int):
|
|
payload_size = int.from_bytes(res[offset : offset + 4], "big", signed=True)
|
|
offset += 4
|
|
payload = res[offset : offset + payload_size]
|
|
offset += payload_size
|
|
return payload, offset
|
|
|
|
def parser_response(self, res) -> Response:
|
|
if isinstance(res, str):
|
|
raise RuntimeError(res)
|
|
response = Response(Header(), Optional())
|
|
# 解析结果
|
|
# header
|
|
header = response.header
|
|
num = 0b00001111
|
|
header.protocol_version = res[0] >> 4 & num
|
|
header.header_size = res[0] & 0x0F
|
|
header.message_type = (res[1] >> 4) & num
|
|
header.message_type_specific_flags = res[1] & 0x0F
|
|
header.serialization_method = res[2] >> num
|
|
header.message_compression = res[2] & 0x0F
|
|
header.reserved = res[3]
|
|
#
|
|
offset = 4
|
|
optional = response.optional
|
|
if header.message_type == FULL_SERVER_RESPONSE or AUDIO_ONLY_RESPONSE:
|
|
# read event
|
|
if header.message_type_specific_flags == MsgTypeFlagWithEvent:
|
|
optional.event = int.from_bytes(res[offset:8], "big", signed=True)
|
|
offset += 4
|
|
if optional.event == EVENT_NONE:
|
|
return response
|
|
# read connectionId
|
|
elif optional.event == EVENT_ConnectionStarted:
|
|
optional.connectionId, offset = self.read_res_content(res, offset)
|
|
elif optional.event == EVENT_ConnectionFailed:
|
|
optional.response_meta_json, offset = self.read_res_content(
|
|
res, offset
|
|
)
|
|
elif (
|
|
optional.event == EVENT_SessionStarted
|
|
or optional.event == EVENT_SessionFailed
|
|
or optional.event == EVENT_SessionFinished
|
|
):
|
|
optional.sessionId, offset = self.read_res_content(res, offset)
|
|
optional.response_meta_json, offset = self.read_res_content(
|
|
res, offset
|
|
)
|
|
else:
|
|
optional.sessionId, offset = self.read_res_content(res, offset)
|
|
response.payload, offset = self.read_res_payload(res, offset)
|
|
|
|
elif header.message_type == ERROR_INFORMATION:
|
|
optional.errorCode = int.from_bytes(
|
|
res[offset : offset + 4], "big", signed=True
|
|
)
|
|
offset += 4
|
|
response.payload, offset = self.read_res_payload(res, offset)
|
|
return response
|
|
|
|
async def start_connection(self):
|
|
header = Header(
|
|
message_type=FULL_CLIENT_REQUEST,
|
|
message_type_specific_flags=MsgTypeFlagWithEvent,
|
|
).as_bytes()
|
|
optional = Optional(event=EVENT_Start_Connection).as_bytes()
|
|
payload = str.encode("{}")
|
|
return await self.send_event(header, optional, payload)
|
|
|
|
def print_response(self, res, tag_msg: str):
|
|
logger.bind(tag=TAG).info(f"===>{tag_msg} header:{res.header.__dict__}")
|
|
logger.bind(tag=TAG).info(f"===>{tag_msg} optional:{res.optional.__dict__}")
|
|
|
|
def get_payload_bytes(
|
|
self,
|
|
uid="1234",
|
|
event=EVENT_NONE,
|
|
text="",
|
|
speaker="",
|
|
audio_format="pcm",
|
|
audio_sample_rate=16000,
|
|
):
|
|
return str.encode(
|
|
json.dumps(
|
|
{
|
|
"user": {"uid": uid},
|
|
"event": event,
|
|
"namespace": "BidirectionalTTS",
|
|
"req_params": {
|
|
"text": text,
|
|
"speaker": speaker,
|
|
"audio_params": {
|
|
"format": audio_format,
|
|
"sample_rate": audio_sample_rate,
|
|
},
|
|
},
|
|
}
|
|
)
|
|
)
|
|
|
|
async def finish_connection(self):
|
|
header = Header(
|
|
message_type=FULL_CLIENT_REQUEST,
|
|
message_type_specific_flags=MsgTypeFlagWithEvent,
|
|
serial_method=JSON,
|
|
).as_bytes()
|
|
optional = Optional(event=EVENT_FinishConnection).as_bytes()
|
|
payload = str.encode("{}")
|
|
await self.send_event(header, optional, payload)
|
|
return
|
|
|
|
async def start_session(self, session_id):
|
|
self.stop_event_response.clear()
|
|
header = Header(
|
|
message_type=FULL_CLIENT_REQUEST,
|
|
message_type_specific_flags=MsgTypeFlagWithEvent,
|
|
serial_method=JSON,
|
|
).as_bytes()
|
|
optional = Optional(event=EVENT_StartSession, sessionId=session_id).as_bytes()
|
|
payload = self.get_payload_bytes(event=EVENT_StartSession, speaker=self.speaker)
|
|
await self.send_event(header, optional, payload)
|
|
|
|
async def finish_session(self, session_id):
|
|
self.stop_event_response.set()
|
|
header = Header(
|
|
message_type=FULL_CLIENT_REQUEST,
|
|
message_type_specific_flags=MsgTypeFlagWithEvent,
|
|
serial_method=JSON,
|
|
).as_bytes()
|
|
optional = Optional(event=EVENT_FinishSession, sessionId=session_id).as_bytes()
|
|
payload = str.encode("{}")
|
|
await self.send_event(header, optional, payload)
|
|
return
|
|
|
|
async def reset(self):
|
|
# 关闭之前的对话
|
|
if self.start_connection_flag:
|
|
await self.finish_connection()
|
|
self.start_connection_flag = False
|
|
await self.start_connection()
|
|
self.start_connection_flag = True
|
|
await super().reset()
|
|
|
|
async def close(self):
|
|
super().close()
|
|
"""资源清理方法"""
|
|
await self.finish_connection()
|
|
await self.ws.close()
|
|
|
|
async def text_to_speak(self, u_id, text, is_last_text=False, is_first_text=False):
|
|
# 发送文本
|
|
await self.send_text(self.speaker, text, u_id)
|
|
return
|
|
|
|
def _start_monitor_tts_response_thread(self):
|
|
# 初始化链接
|
|
asyncio.run_coroutine_threadsafe(
|
|
self._start_monitor_tts_response(), loop=self.loop
|
|
)
|
|
|
|
async def _start_monitor_tts_response(self):
|
|
chunk_total = b""
|
|
while not self.stop_event.is_set():
|
|
try:
|
|
msg = await self.ws.recv() # 确保 `recv()` 运行在同一个 event loop
|
|
res = self.parser_response(msg)
|
|
self.print_response(res, "send_text res:")
|
|
|
|
if (
|
|
res.optional.event == EVENT_TTSResponse
|
|
and res.header.message_type == AUDIO_ONLY_RESPONSE
|
|
):
|
|
logger.bind(tag=TAG).info(f"推送数据到队列里面~~")
|
|
opus_datas = self.wav_to_opus_data_audio_raw(res.payload)
|
|
logger.bind(tag=TAG).info(f"推送数据到队列里面帧数~~{len(opus_datas)}")
|
|
self.tts_audio_queue.put(
|
|
TTSMessageDTO(
|
|
u_id=self.u_id,
|
|
msg_type=MsgType.TTS_TEXT_RESPONSE,
|
|
content=opus_datas,
|
|
tts_finish_text="",
|
|
sentence_type=None,
|
|
duration=0,
|
|
)
|
|
)
|
|
elif res.optional.event == EVENT_TTSSentenceStart:
|
|
json_data = json.loads(res.payload.decode("utf-8"))
|
|
self.tts_text = json_data.get("text", "")
|
|
logger.bind(tag=TAG).info(f"句子开始~~{self.tts_text}")
|
|
self.tts_audio_queue.put(
|
|
TTSMessageDTO(
|
|
u_id=self.u_id,
|
|
msg_type=MsgType.TTS_TEXT_RESPONSE,
|
|
content=[],
|
|
tts_finish_text=self.tts_text,
|
|
sentence_type=SentenceType.SENTENCE_START,
|
|
)
|
|
)
|
|
elif res.optional.event == EVENT_TTSSentenceEnd:
|
|
logger.bind(tag=TAG).info(f"句子结束~~{self.tts_text}")
|
|
self.tts_audio_queue.put(
|
|
TTSMessageDTO(
|
|
u_id=self.u_id,
|
|
msg_type=MsgType.TTS_TEXT_RESPONSE,
|
|
content=[],
|
|
tts_finish_text=self.tts_text,
|
|
sentence_type=SentenceType.SENTENCE_END,
|
|
)
|
|
)
|
|
elif res.optional.event == EVENT_SessionFinished:
|
|
logger.bind(tag=TAG).info(f"会话结束~~,最后一句补零")
|
|
opus_datas = self.wav_to_opus_data_audio_raw(b"", is_end=True)
|
|
self.tts_audio_queue.put(
|
|
TTSMessageDTO(
|
|
u_id=self.u_id,
|
|
msg_type=MsgType.TTS_TEXT_RESPONSE,
|
|
content=opus_datas,
|
|
tts_finish_text="",
|
|
sentence_type=None,
|
|
duration=0,
|
|
)
|
|
)
|
|
self.tts_audio_queue.put(
|
|
TTSMessageDTO(
|
|
u_id=self.u_id,
|
|
msg_type=MsgType.STOP_TTS_RESPONSE,
|
|
content=[],
|
|
tts_finish_text=self.tts_text,
|
|
sentence_type=SentenceType.SENTENCE_END,
|
|
)
|
|
)
|
|
else:
|
|
continue
|
|
except websockets.ConnectionClosed:
|
|
break # 连接关闭时退出监听
|
|
except Exception as e:
|
|
logger.bind(tag=TAG).error(f"Error in _start_monitor_tts_response: {e}")
|
|
traceback.print_exc()
|
|
continue
|