Files
tra-app/src/pages/sparring/js/ProtocolCodec.ts
T

395 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 导入 GZIP 处理库(浏览器/Node.js 通用,需提前安装:npm install pako @types/pako
// import * as pako from "pako";
import { TextDecoder } from 'text-encoding';
/**
* 协议常量类:存储协议核心配置(不可修改,确保前后端一致)
*/
export class ProtocolConst {
/** 协议版本:0~15(4位,存储在字节0低4位) */
static readonly PROTOCOL_VERSION = 0b0001;
/** 头部固定长度:8字节(字节0~7,结构严格定义,不可修改) */
static readonly HEADER_SIZE = 8;
/**
* 最大包体大小:16MB3字节长度最大支持0xFFFFFF=16777215字节≈16MB
* 4-6字节存储(24位),最大支持16MB,满足大部分场景且避免长度字段冗余
*/
static readonly MAX_BODY_SIZE = 0xFFFFFF; // 16777215字节 ≈16MB
/** 字符串编码格式:UTF-8(统一前后端字符串编解码,避免乱码) */
static readonly STRING_ENCODING = "utf-8" as const;
}
/**
* 消息类型枚举(4位,存储在字节0高4位,0~15范围)
* 二进制标识,统一编码风格
*/
export enum MessageType {
PING = 0b0001, // 心跳消息(支持空包体)
AUDIO_DATA = 0b0010, // 纯音频数据
TEXT_MESSAGE = 0b0011, // 大模型回复的文本消息
CONTROL_CMD = 0b0100, // 控制指令
IDENTITY = 0b0101, // 身份校验包json格式
ERROR = 0b0110, // 错误信息json格式
TIP_MESSAGE = 0b0111 // 大模型生成的回答提示
// 预留12种类型用于扩展(0b0100 ~ 0b1111
}
/**
* 控制消息枚举
* 二进制标识,统一编码风格
*/
export enum ControlCode {
FINISH_PUSH = 0b0001, // 本轮tts推流结束
FINISH_PLAY = 0b0010, // 语音播放结束(发后端)
AI_ANSWER_BEGIN = 0b0011, // 本轮AI回答的文本内容开始
AI_ANSWER_OVER = 0b0100, // 本轮AI回答的文本内容已经全部返回
AI_CLUE = 0b0101, // 需要AI提示命令(发后端)
AI_CLUE_OVER = 0b0110, // AI提示命令已经全部返回
FRNOT_TO_SERVER_OVER = 0b0111, // 语音按钮已经抬起
SERVER_TO_FRNOT_OVER = 0b1000, // 后端已经返回本次全部内容
}
/**
* 错误类型枚举
* 二进制标识,统一编码风格
*/
export enum ErrorType {
PING = 2, // 认证错误
AUDIO_DATA = 0b0010, // 纯音频数据
TEXT_MESSAGE = 0b0011, // 大模型回复的文本消息
CONTROL_CMD = 0b0100, // 控制指令
IDENTITY = 0b0101, // 身份校验包json格式
ERROR = 0b0110, // 错误信息json格式
TIP_MESSAGE = 0b0111 // 大模型生成的回答提示
// 预留12种类型用于扩展(0b0100 ~ 0b1111
}
/**
* 序列化方式枚举(3位,存储在字节1高3位,1~8范围)
* 二进制标识,统一编码风格(1~8对应0b001~0b111
*/
export enum SerializationType {
RAW = 0b001, // 原始二进制(1
JSON = 0b010, // JSON 格式(2
STRING = 0b011, // 直接字符串(3
// 预留5种方式用于扩展(0b100 ~ 0b111
}
/**
* 压缩方式枚举(3位,存储在字节1中3位,1~8范围)
* 二进制标识,统一编码风格(1~8对应0b001~0b111
*/
export enum CompressionType {
NONE = 0b001, // 无压缩(1,默认值)
GZIP = 0b010, // GZIP 压缩(2
// 预留6种方式用于扩展(0b011 ~ 0b111
}
/**
* 控制指令枚举(配合 MessageType.CONTROL_CMD 使用)
*/
export enum ControlCommand {
START_PLAY = 0b0001, // 一句话播放开始
FINISH_PLAY = 0b0010, // 一句话播放结束
}
/**
* 解包返回结果接口
*/
export interface UnpackedResult {
msgType : MessageType;
msgTypeName : keyof typeof MessageType;
serialization : SerializationType;
serializationName : keyof typeof SerializationType;
compression : CompressionType;
compressionName : keyof typeof CompressionType;
sequence : number; // 消息顺序号(0~65535,默认0
body : Uint8Array | string | object | unknown[] | null; // PING 消息可能返回 null
}
/**
* 打包入参类型别名(支持 PING 消息传入 null)
*/
type PackBody = Uint8Array | string | object | unknown[] | null;
type OptionalSerialization = SerializationType | null | undefined;
/**
* 协议编解码工具类(支持 PING 消息空包体)
*/
export class ProtocolCodec {
/**
* 打包协议包
* @param msgType 消息类型(二进制枚举,0~15)
* @param body 业务数据(PING 消息可传 null/undefined,其他类型必填)
* @param sequence 消息顺序号(0~65535,可选,默认0)
* @param serialization 序列化方式(二进制枚举,1~8,可选,自动推导)
* @param compression 压缩方式(二进制枚举,1~8,可选,默认NONE=0b001
* @returns 完整协议包
* @throws 类型错误、范围错误、包体过大等异常
*/
static pack(
msgType : MessageType,
body : PackBody = null, // 默认为 null,支持 PING 消息空包体
sequence : number = 0, // 可选参数,默认0
serialization : OptionalSerialization = null,
compression : CompressionType = CompressionType.NONE
) : Uint8Array {
// 校验顺序号范围(0~65535
if (!Number.isInteger(sequence) || sequence < 0 || sequence > 0xFFFF) {
throw new RangeError(`消息顺序号必须是0~65535的整数,当前传入:${sequence}`);
}
// 特殊处理:PING 消息允许空包体,强制 RAW 序列化(空二进制)
if (msgType === MessageType.PING) {
// PING 消息忽略传入的序列化方式,强制使用 RAW(空二进制最高效)
serialization = SerializationType.RAW;
// 若传入空包体,统一处理为空 Uint8Array
// body = body === null || body === undefined ? new Uint8Array(0) : body;
// PING 消息仅支持空包体或 Uint8Array(防止误传其他类型)
if (!(body instanceof Uint8Array)) {
throw new TypeError(`PING 消息仅支持空包体或 Uint8Array 类型,当前传入:${typeof body}`);
}
} else {
// 非 PING 消息:包体必填
if (body === null || body === undefined) {
throw new TypeError(`非 PING 消息(${MessageType[msgType]})包体不能为空`);
}
}
// 1. 自动推导序列化方式(非 PING 消息)
if (serialization === null || serialization === undefined && msgType !== MessageType.PING) {
if (msgType === MessageType.AUDIO_DATA) {
serialization = SerializationType.RAW;
} else if (msgType === MessageType.TEXT_MESSAGE) {
serialization = SerializationType.STRING;
} else if (msgType === MessageType.CONTROL_CMD) {
serialization = SerializationType.JSON;
} else if (msgType === MessageType.IDENTITY) {
serialization = SerializationType.JSON;
} else if (msgType === MessageType.ERROR) {
serialization = SerializationType.JSON;
} else {
throw new Error(`不支持的消息类型:${MessageType[msgType]}(值:${msgType}`);
}
}
// 2. 校验枚举值范围(3位存储,1~8即0b001~0b111
if (serialization < 0b001 || serialization > 0b111) {
throw new RangeError(`序列化方式必须在1~8(0b001~0b111)范围内,当前传入:${serialization}0b${serialization.toString(2).padStart(3, '0')}`);
}
if (compression < 0b001 || compression > 0b111) {
throw new RangeError(`压缩方式必须在1~8(0b001~0b111)范围内,当前传入:${compression}0b${compression.toString(2).padStart(3, '0')}`);
}
// 3. 序列化包体
let serializedBody : Uint8Array;
const textEncoder = new TextEncoder();
switch (serialization) {
case SerializationType.RAW:
// RAW 序列化:支持 Uint8ArrayPING 消息可能是空 Uint8Array
// if (!(body instanceof Uint8Array)) {
// throw new TypeError(`RAW 序列化要求 body 必须是 Uint8Array 类型,当前传入:${typeof body}`);
// }
serializedBody = body instanceof Uint8Array ? body : new Uint8Array(body as ArrayBuffer);
break;
case SerializationType.STRING:
// STRING 序列化:必须传入字符串(非 PING 消息已校验非空)
serializedBody = textEncoder.encode(body as string);
break;
case SerializationType.JSON:
// 断言:body 是 string 或 object
if (typeof body === 'string') {
serializedBody = textEncoder.encode(body);
} else {
serializedBody = textEncoder.encode(JSON.stringify(body));
}
break;
default:
throw new Error(`不支持的序列化方式:${SerializationType[serialization]}(值:${serialization}0b${serialization.toString(2).padStart(3, '0')}`);
}
// 4. 压缩:暂不支持,直接赋值
const compressedBody = serializedBody;
// 5. 校验包体大小(24位长度最大支持0xFFFFFF=16777215字节)
const bodyLen = compressedBody.length;
if (bodyLen > ProtocolConst.MAX_BODY_SIZE) {
throw new Error(
`包体过大(${bodyLen}字节),最大支持${ProtocolConst.MAX_BODY_SIZE}字节(≈16MB`
);
}
// 6. 构造头部(8字节,按最新结构)
const header = new Uint8Array(ProtocolConst.HEADER_SIZE);
// 字节0:消息类型(高4位) + 协议版本(低4位)
header[0] = ((msgType & 0x0F) << 4) | (ProtocolConst.PROTOCOL_VERSION & 0x0F);
// 字节1:序列化方式(高3位) + 压缩方式(中3位) + 保留位(低2位,填0)
header[1] = ((serialization & 0x07) << 5) | ((compression & 0x07) << 2) | 0x00;
// 字节2~3:消息顺序号(16位大端序,0~65535)
header[2] = (sequence >> 8) & 0xFF; // 顺序号高8位
header[3] = sequence & 0xFF; // 顺序号低8位
// 字节4~6:消息体长度(24位大端序,0~0xFFFFFF
header[4] = (bodyLen >> 16) & 0xFF; // 长度高8位
header[5] = (bodyLen >> 8) & 0xFF; // 长度中8位
header[6] = bodyLen & 0xFF; // 长度低8位
// 字节7:保留位(固定填0x00)
header[7] = 0x00;
// 7. 拼接头部和包体
const totalLen = ProtocolConst.HEADER_SIZE + bodyLen;
const packet = new Uint8Array(totalLen);
packet.set(header, 0);
packet.set(compressedBody, ProtocolConst.HEADER_SIZE);
return packet;
}
/**
* 解包协议包
* @param packet 完整协议包
* @returns 结构化解包结果(含顺序号)
* @throws 各种解析异常
*/
static unpack(packet : Uint8Array | ArrayBuffer) : UnpackedResult {
const uint8Packet = packet instanceof ArrayBuffer
? new Uint8Array(packet)
: packet;
// 1. 校验包长度(至少8字节头部)
if (uint8Packet.length < ProtocolConst.HEADER_SIZE) {
throw new Error(
`包长度过短(${uint8Packet.length}字节),至少需要${ProtocolConst.HEADER_SIZE}字节头部`
);
}
// 2. 拆分头部和包体
const header = uint8Packet.subarray(0, ProtocolConst.HEADER_SIZE);
const bodyBuffer = uint8Packet.subarray(ProtocolConst.HEADER_SIZE);
// 3. 解析头部字段
// 字节0:消息类型(高4位) + 协议版本(低4位)
const byte0 = header[0];
const msgType = (byte0 >> 4) & 0x0F; // 消息类型(0~15
const version = byte0 & 0x0F; // 协议版本(0~15
// 校验消息类型
if (!Object.values(MessageType).includes(msgType as MessageType)) {
throw new Error(`非法消息类型:${msgType}0b${msgType.toString(2).padStart(4, '0')}`);
}
// 校验版本
if (version !== ProtocolConst.PROTOCOL_VERSION) {
throw new Error(
`协议版本不匹配:收到v${version}0b${version.toString(2).padStart(4, '0')}),当前支持v${ProtocolConst.PROTOCOL_VERSION}0b${ProtocolConst.PROTOCOL_VERSION.toString(2).padStart(4, '0')}`
);
}
// 字节1:序列化方式(高3位) + 压缩方式(中3位) + 保留位(低2位)
const byte1 = header[1];
const serialization = (byte1 >> 5) & 0x07; // 高3位(1~8
const compression = (byte1 >> 2) & 0x07; // 中3位(1~8
// 保留位:(byte1 & 0x03),暂不处理
// 校验序列化方式
if (!Object.values(SerializationType).includes(serialization as SerializationType)) {
throw new Error(`非法序列化方式:${serialization}0b${serialization.toString(2).padStart(3, '0')}`);
}
// 校验压缩方式
if (!Object.values(CompressionType).includes(compression as CompressionType)) {
throw new Error(`非法压缩方式:${compression}0b${compression.toString(2).padStart(3, '0')}`);
}
// 字节2~3:消息顺序号(16位大端序)
const sequence = (header[2] << 8) | header[3]; // 0~65535
// 字节4~6:消息体长度(24位大端序),字节7:保留位(忽略)
const bodyLen = (header[4] << 16) | (header[5] << 8) | header[6];
// 校验包体长度(空包体时 bodyBuffer.length 应为0
if (bodyBuffer.length !== bodyLen) {
throw new Error(
`包体长度不匹配:头部声明${bodyLen}字节,实际接收${bodyBuffer.length}字节`
);
}
// 4. 解压包体
let decompressedBody : Uint8Array;
if (compression === CompressionType.NONE) {
decompressedBody = bodyBuffer;
} else if (compression === CompressionType.GZIP) {
// 若需启用GZIP,取消注释下方代码
// try {
// decompressedBody = pako.ungzip(bodyBuffer);
// } catch (e) {
// throw new Error(`GZIP 解压失败:${(e as Error).message}`);
// }
throw new Error("GZIP 解压暂未启用,请导入pako库并取消对应代码注释");
} else {
throw new Error(`不支持的压缩方式:${CompressionType[compression]}(值:${compression}0b${compression.toString(2).padStart(3, '0')}`);
}
// 5. 反序列化包体(PING 消息空包体返回 null)
let body : UnpackedResult["body"];
const textDecoder = new TextDecoder();
// 特殊处理:空包体(PING 消息常见)返回 null
if (decompressedBody.length === 0) {
body = null;
} else {
switch (serialization) {
case SerializationType.RAW:
body = decompressedBody;
break;
case SerializationType.STRING:
try {
body = textDecoder.decode(decompressedBody);
} catch (e) {
throw new Error(`STRING 反序列化失败:UTF-8 解码错误`);
}
break;
case SerializationType.JSON:
try {
const jsonStr = textDecoder.decode(decompressedBody);
body = JSON.parse(jsonStr);
} catch (e) {
if (e instanceof SyntaxError) {
throw new Error(`JSON 反序列化失败:格式错误(${(e as Error).message}`);
} else {
throw new Error(`JSON 反序列化失败:${(e as Error).message}`);
}
}
break;
default:
throw new Error(`不支持的序列化方式:${SerializationType[serialization]}(值:${serialization}0b${serialization.toString(2).padStart(3, '0')}`);
}
}
// 6. 返回解包结果
return {
msgType: msgType as MessageType,
msgTypeName: MessageType[msgType] as keyof typeof MessageType,
serialization: serialization as SerializationType,
serializationName: SerializationType[serialization] as keyof typeof SerializationType,
compression: compression as CompressionType,
compressionName: CompressionType[compression] as keyof typeof CompressionType,
sequence: sequence,
body: body,
};
}
}