From 3201fc026d6ed88b5cea84f6edb6a20ede96c7d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B0=E5=B2=A9?= Date: Thu, 4 Dec 2025 16:23:07 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E5=BC=B9=E7=AA=97=E4=B8=A4?= =?UTF-8?q?=E6=AC=A1=E6=97=B6=E5=80=99=E6=97=A0=E6=B3=95=E5=BC=B9=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.development | 8 +- package.json | 3 +- src/components/points-and-badge/check-in.vue | 7 +- .../points-and-badge/usePointsAndBadge.js | 184 +++++---- src/pages/sparring/js/ProtocolCodec.ts | 365 ++++++++++++++++++ src/pages/sparring/js/StreamPlayer.js | 201 ++++++++++ src/pages/sparring/talk.vue | 304 +++++++++++++-- 7 files changed, 958 insertions(+), 114 deletions(-) create mode 100644 src/pages/sparring/js/ProtocolCodec.ts create mode 100644 src/pages/sparring/js/StreamPlayer.js diff --git a/.env.development b/.env.development index 4e467440..cf4545bf 100644 --- a/.env.development +++ b/.env.development @@ -5,7 +5,7 @@ ENV = 'development' # VITE_APP_BASE_API_Url = 'https://aits.jlbank.com.cn:7001' -#VITE_APP_BASE_API_Url = 'https://aitstest.jlbank.com.cn:7001' +#VITE_APP_BASE_API_Url = 'https://aitstest.jlbank.com.cn:7002' @@ -17,7 +17,7 @@ ENV = 'development' # VITE_APP_BASE_API_Url = 'http://25.18.122.65:7001' # sit -# VITE_APP_BASE_API_Url = 'http://25.18.122.91:9786' +VITE_APP_BASE_API_Url = 'http://25.18.122.91:9786' # UAT # VITE_APP_BASE_API_Url = 'http://25.18.122.78:9786' @@ -32,6 +32,6 @@ ENV = 'development' # h5专用地址x2 # VITE_APP_BASE_H5_API_Url_TRAAPP = 'http://25.64.32.150:9601' # dev -#VITE_APP_BASE_H5_API_Url = 'http://25.18.122.65:7001' +# VITE_APP_BASE_H5_API_Url = 'http://25.18.122.65:7001' # sit -# VITE_APP_BASE_H5_API_Url = 'http://25.18.122.91:9786' \ No newline at end of file +#VITE_APP_BASE_H5_API_Url = 'http://25.18.122.91:9786' \ No newline at end of file diff --git a/package.json b/package.json index b9725b1e..b0df4ff2 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,8 @@ "terser": "^5.42.0", "uuid": "^11.1.0", "vue": "^3.5.11", - "vue-i18n": "^9.1.9" + "vue-i18n": "^9.1.9", + "text-encoding": "^0.7.0" }, "devDependencies": { "@dcloudio/types": "^3.4.8", diff --git a/src/components/points-and-badge/check-in.vue b/src/components/points-and-badge/check-in.vue index 35963216..f1a6a7ba 100644 --- a/src/components/points-and-badge/check-in.vue +++ b/src/components/points-and-badge/check-in.vue @@ -138,8 +138,11 @@ }, failCb: (res) => { checkInLoading.value = false; - console.log('签到失败', res); - common.msg('签到失败,请稍后再试'); + if (res.data.rtnCode === '0002') { + common.msg(res.data.message); + } else { + common.msg('签到失败,请稍后再试'); + } } }); }; diff --git a/src/components/points-and-badge/usePointsAndBadge.js b/src/components/points-and-badge/usePointsAndBadge.js index a702c170..4e4bb02f 100644 --- a/src/components/points-and-badge/usePointsAndBadge.js +++ b/src/components/points-and-badge/usePointsAndBadge.js @@ -34,88 +34,118 @@ export default function usePointsAndBadge() { * @param {any} res - 接口返回数据 */ const handlePointsBadgePopup = (points, pointsBadges, complete = () => {}, res = {}) => { - // AI问答仅弹徽章弹窗 - if (points?.length > 0) { - // 普通场景:先弹积分弹窗,再弹徽章弹窗 - pointsRef.value?.open(points, pointsBadges, (status, pointsList, badgesList) => { - pointsRef.value.close(); - // 有徽章则弹徽章弹窗,否则直接完成 - if (badgesList?.length > 0) { - nextTick(() => { - setTimeout(() => { - badgeRef.value.open(pointsList, badgesList, () => { - badgeRef.value.close(); - complete(true, '积分获取成功', res); + // AI问答仅弹徽章弹窗 + if (points?.length > 0) { + // 普通场景:先弹积分弹窗,再弹徽章弹窗 + pointsRef.value?.open(points, pointsBadges, (status, pointsList, badgesList) => { + pointsRef.value.close(); + if(pointsList.length > 0) { + setTimeout(() => { + pointsRef.value?.open(pointsList, badgesList, (status, pointsList, + badgesList) => { + pointsRef.value.close(); + // 有徽章则弹徽章弹窗,否则直接完成 + if (badgesList?.length > 0) { + nextTick(() => { + setTimeout(() => { + badgeRef.value.open(pointsList, + badgesList, () => { + badgeRef.value.close(); + complete(true, '积分获取成功', + res); + }); + }, pop_up_time); }); - }, pop_up_time); + } else { + complete(true, '积分获取成功', res); + } }); - } else { - complete(true, '积分获取成功', res); - } - }); - } else if (pointsBadges?.length > 0) { - badgeRef.value.open([], pointsBadges, () => { - badgeRef.value.close(); + }, 300) + } else if (badgesList?.length > 0) { + // 有徽章则弹徽章弹窗,否则直接完成 + nextTick(() => { + setTimeout(() => { + badgeRef.value.open(pointsList, badgesList, () => { + badgeRef.value.close(); + complete(true, '积分获取成功', res); + }); + }, pop_up_time); + }); + } else { complete(true, '积分获取成功', res); - }); - }else{ + } + }); + } else if (pointsBadges?.length > 0) { + badgeRef.value.open([], pointsBadges, () => { + badgeRef.value.close(); + complete(true, '积分获取成功', res); + }); + } else { + complete(false, '积分接口返回数据异常', res); + } + }; + + /** + * 积分和徽章处理主方法 + * @param {string} type - 任务类型(04:课程练习,06:AI问答,其他:普通任务) + * @param {string|null} taskId - 任务ID + * @param {Function} complete - 完成回调(status, message, data) + */ + const pointAndBadgesRun = async (type, taskId = '', complete = () => {}) => { + + try { + console.log('调用积分接口'); + const res = await addPointsByTask({ + type, + taskId + }); + + // 防御性处理:确保res.body存在 + if (!res?.body) { complete(false, '积分接口返回数据异常', res); + return; } - }; - - /** - * 积分和徽章处理主方法 - * @param {string} type - 任务类型(04:课程练习,06:AI问答,其他:普通任务) - * @param {string|null} taskId - 任务ID - * @param {Function} complete - 完成回调(status, message, data) - */ - const pointAndBadgesRun = async (type, taskId = '', complete = () => {}) => { - - try { - console.log('调用积分接口'); - const res = await addPointsByTask({ - type, - taskId - }); - - // 防御性处理:确保res.body存在 - if (!res?.body) { - complete(false, '积分接口返回数据异常', res); + const { + points, + pointsBadges, + message = '' + } = res.body; + // const points = [{ + // "changePoints": 1, + // "message": '学习时长累计1小时' + // }, + // { + // "changePoints": 10, + // "message": '完成课程学习' + // } + // ] + // 分支逻辑:按类型处理 + switch (type) { + case '04': // 04:课程练习,自定义返回逻辑,不弹弹窗 + complete(true, '', res.body); // 补充回调,避免调用方等待 return; - } - const { - points, - pointsBadges, - message = '' - } = res.body; - - // 分支逻辑:按类型处理 - switch (type) { - case '04': // 04:课程练习,自定义返回逻辑,不弹弹窗 - complete(true, '', res.body); // 补充回调,避免调用方等待 - return; - case '06': // 06:AI问答,仅弹徽章弹窗 - if (pointsBadges?.length > 0) { - handlePointsBadgePopup(points, pointsBadges, complete, res); - } else { - complete(true, '', res); - } - return; - // 其他类型:弹积分+徽章弹窗 - default: + case '06': // 06:AI问答,仅弹徽章弹窗 + if (pointsBadges?.length > 0) { handlePointsBadgePopup(points, pointsBadges, complete, res); - return; - } - } catch (error) { - // 细化错误信息:区分网络错误/接口错误 - const errorMsg = error?.message || '积分获取失败'; - complete(false, `积分获取失败:${errorMsg}`, error); + } else { + complete(true, '', res); + } + return; + // 其他类型:弹积分+徽章弹窗 + default: + handlePointsBadgePopup(points, pointsBadges, complete, res); + return; } - }; - return { - pointAndBadgesRun, - handlePointsBadgePopup, - badgeRef, - pointsRef - }; - } \ No newline at end of file + } catch (error) { + // 细化错误信息:区分网络错误/接口错误 + const errorMsg = error?.message || '积分获取失败'; + complete(false, `积分获取失败:${errorMsg}`, error); + } + }; + return { + pointAndBadgesRun, + handlePointsBadgePopup, + badgeRef, + pointsRef + }; +} \ No newline at end of file diff --git a/src/pages/sparring/js/ProtocolCodec.ts b/src/pages/sparring/js/ProtocolCodec.ts new file mode 100644 index 00000000..3beeeb50 --- /dev/null +++ b/src/pages/sparring/js/ProtocolCodec.ts @@ -0,0 +1,365 @@ +// 导入 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; + + /** + * 最大包体大小:16MB(3字节长度最大支持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格式 + // 预留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 { + HEARTBEAT = 0b0001, + PAUSE = 0b0010, + RESUME = 0b0011, + STOP = 0b0100, +} + +/** + * 解包返回结果接口 + */ +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 序列化:支持 Uint8Array(PING 消息可能是空 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, + }; + } +} \ No newline at end of file diff --git a/src/pages/sparring/js/StreamPlayer.js b/src/pages/sparring/js/StreamPlayer.js new file mode 100644 index 00000000..f0340d68 --- /dev/null +++ b/src/pages/sparring/js/StreamPlayer.js @@ -0,0 +1,201 @@ +/** + * 支持安卓、iOS、H5的pcm格式, + * 如果想支持mp3,需要增加mp3相关解码合并处理等,个人测试效果不太好,所以没有加 + */ +export class StreamPlayer { + constructor({ + inputSampleRate = 16000, + numChannels = 1, + bitDepth = 16, + littleEndian = true, + pcmType = 'int', + callback = ()=>{} + } = {}) { + // 参数校验 + if (![16, 32].includes(bitDepth)) throw new Error('bitDepth 必须是 16 或 32'); + if (inputSampleRate <= 0) throw new Error('采样率必须大于 0'); + if (!['int', 'float'].includes(pcmType)) throw new Error('pcmType 必须是 int 或 float'); + + this.audioContext = new(window.AudioContext || window.webkitAudioContext)(); + this.inputSampleRate = inputSampleRate; + this.numChannels = numChannels; + this.bitDepth = bitDepth; + this.littleEndian = littleEndian; + this.pcmType = pcmType; + this.audioQueue = []; + this.playbackEndTime = 0; + this.currentSource = null; + this.lastBlockTail = null; // 用于交叉淡入淡出 + this.callback = callback; + } + + appendChunk(audioData) { + console.log('this.audioQueue', this.audioQueue.length); + this.audioQueue.push(audioData); + this._processQueue(); + } + + destroy() { + this.audioQueue = []; + this.playbackEndTime = 0; + if (this.currentSource) { + this.currentSource.stop(); + this.currentSource = null; + } + if (this.audioContext) { + this.audioContext.close(); + this.audioContext = null; + } + } + + async _processQueue() { + if(this.audioQueue.length === 0) { + this.callback('ended') + return + } + if (!this.audioContext || this.currentSource) return; + const buffer = this._mergeArrayBuffers(this.audioQueue) + this.audioQueue = [] + try { + const audioBuffer = this._convertPCM(buffer); + await this._schedulePlay(audioBuffer); + this._processQueue(); + } catch (err) { + console.error('音频处理失败:', err.name, err.message, err.stack); + } + } + + _convertPCM(buffer) { + // 1. 数据对齐:确保长度是样本大小的整数倍 + const bytesPerSample = this.bitDepth / 8; + const requiredBytes = bytesPerSample * this.numChannels; + const validByteLength = Math.floor(buffer.byteLength / requiredBytes) * requiredBytes; + const validBuffer = buffer.slice(0, validByteLength); + const dataView = new DataView(validBuffer); + const numSamples = validByteLength / requiredBytes; + + // 2. 创建音频缓冲区 + const audioBuffer = this.audioContext.createBuffer( + this.numChannels, + numSamples, + this.inputSampleRate + ); + + // 3. 解析PCM数据 + for (let ch = 0; ch < this.numChannels; ch++) { + const channelData = audioBuffer.getChannelData(ch); + for (let i = 0; i < numSamples; i++) { + // 计算字节位置(修正后的多声道索引) + const pos = (i * this.numChannels * bytesPerSample) + (ch * bytesPerSample); + let value; + + // 根据位深和数据类型解析 + if (this.bitDepth === 16) { + value = dataView.getInt16(pos, this.littleEndian) / 32768; + } else { + if (this.pcmType === 'int') { + value = dataView.getInt32(pos, this.littleEndian) / 2147483648; + } else { + value = dataView.getFloat32(pos, this.littleEndian); + } + } + channelData[i] = value; + } + + // 4. 消除直流偏移 + let sum = 0; + for (let i = 0; i < channelData.length; i++) { + sum += channelData[i]; + } + const dcOffset = sum / channelData.length; + for (let i = 0; i < channelData.length; i++) { + channelData[i] -= dcOffset; + } + + // 5. 块内淡入淡出(减少边界突变) + const fadeLength = Math.min(100, channelData.length); + for (let i = 0; i < fadeLength; i++) { + channelData[i] *= (i / fadeLength); + } + const startFadeOut = Math.max(0, channelData.length - fadeLength); + for (let i = startFadeOut; i < channelData.length; i++) { + const factor = 1 - (i - startFadeOut) / fadeLength; + channelData[i] *= factor; + } + } + + // 6. 块间交叉淡入淡出(解决重音关键点) + const crossfadeLength = 50; + if (this.lastBlockTail && this.lastBlockTail.length === this.numChannels) { + for (let ch = 0; ch < this.numChannels; ch++) { + const channelData = audioBuffer.getChannelData(ch); + const prevTail = this.lastBlockTail[ch]; + + // 只处理有足够长度交叉的情况 + if (prevTail.length >= crossfadeLength && channelData.length >= crossfadeLength) { + for (let i = 0; i < crossfadeLength; i++) { + const prevWeight = (crossfadeLength - i) / crossfadeLength; + const currWeight = i / crossfadeLength; + channelData[i] = channelData[i] * currWeight + prevTail[i] * prevWeight; + } + } + } + } + + // 7. 保存当前块尾部用于下一次交叉 + this.lastBlockTail = []; + for (let ch = 0; ch < this.numChannels; ch++) { + const channelData = audioBuffer.getChannelData(ch); + const tailStart = Math.max(0, channelData.length - crossfadeLength); + this.lastBlockTail[ch] = channelData.slice(tailStart); + } + + return audioBuffer; + } + _schedulePlay(audioBuffer) { + return new Promise(resolve => { + const source = this.audioContext.createBufferSource(); + source.buffer = audioBuffer; + source.connect(this.audioContext.destination); + + const plannedStartTime = this.playbackEndTime; + const now = this.audioContext.currentTime; + let startTime = Math.max(plannedStartTime, now); + + // 动态速率调整(示例,需根据实际测试调整阈值) + const timeDrift = plannedStartTime - now; + if (timeDrift < -0.02) { + source.playbackRate.value = Math.min(1.04, 1 - (timeDrift / audioBuffer.duration)); + } else if (timeDrift > 0.02) { + source.playbackRate.value = Math.max(0.96, 1 - (timeDrift / audioBuffer.duration)); + } else { + source.playbackRate.value = 1.0; + } + + source.start(startTime); + this.playbackEndTime = startTime + (audioBuffer.duration / source.playbackRate.value); + + source.onended = () => { + this.currentSource = null; + resolve(); + }; + this.currentSource = source; + }); + } + + // 合并多个 ArrayBuffer 的辅助函数 + _mergeArrayBuffers(buffers) { + let totalLength = 0; + for (let buffer of buffers) { + totalLength += buffer.byteLength; + } + const result = new Uint8Array(totalLength); + let offset = 0; + for (let buffer of buffers) { + const view = new Uint8Array(buffer); + result.set(view, offset); + offset += view.length; + } + return result.buffer; + } +} \ No newline at end of file diff --git a/src/pages/sparring/talk.vue b/src/pages/sparring/talk.vue index 9c936d0c..7947f860 100644 --- a/src/pages/sparring/talk.vue +++ b/src/pages/sparring/talk.vue @@ -1,15 +1,31 @@ + +