diff --git a/src/pages/test/ProtocolCodec.ts b/src/pages/test/ProtocolCodec.ts new file mode 100644 index 00000000..97851d86 --- /dev/null +++ b/src/pages/test/ProtocolCodec.ts @@ -0,0 +1,365 @@ +// 导入 GZIP 处理库(浏览器/Node.js 通用,需提前安装:npm install pako @types/pako) +// import * as pako from "pako"; + +/** + * 协议常量类:存储协议核心配置(不可修改,确保前后端一致) + */ +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/test/testChat.vue b/src/pages/test/testChat.vue index 971d6b05..c1c1b6eb 100644 --- a/src/pages/test/testChat.vue +++ b/src/pages/test/testChat.vue @@ -1,223 +1,235 @@ + \ No newline at end of file diff --git a/src/uni_modules/sdx-StreamPlayer/changelog.md b/src/uni_modules/sdx-StreamPlayer/changelog.md new file mode 100644 index 00000000..96fca03e --- /dev/null +++ b/src/uni_modules/sdx-StreamPlayer/changelog.md @@ -0,0 +1,18 @@ +## 2.0.5(2025-08-29) +修复示例中的引用错误和新增描述 +## 2.0.4(2025-08-29) +修复示例引用赋值回调函数错误的问题 +## 2.0.3(2025-05-16) +新增使用说明 +## 2.0.2(2025-05-16) +1. 新增结束回调函数 +2. 新增主动结束播放 +3. 修改为vue2写法(renderjs层回调逻辑层方法必需) +## 2.0.1(2025-04-25) +优化对爆破音和重音的处理 +## 2.0.0(2025-04-24) +之前版本是收到数据转成本地mp3文件进行播放,在切换播放时会有卡顿,且不支持pcm格式,这次更换技术栈解决这个问题。 +1. 新增StreamPlayer类,可以兼容安卓、iOS、h5播放pcm二进制流。 +2.新增H5AndAndriod类,可以在安卓、h5播放pcm、mp3二进制流。由于iOS不支持MSE所以暂不支持iOS。 +## 1.0.0(2025-03-21) +1. 实现在h5和app中播放二进制音频流 diff --git a/src/uni_modules/sdx-StreamPlayer/components/sdx-StreamPlayer/sdx-StreamPlayer.vue b/src/uni_modules/sdx-StreamPlayer/components/sdx-StreamPlayer/sdx-StreamPlayer.vue new file mode 100644 index 00000000..b42e3f1d --- /dev/null +++ b/src/uni_modules/sdx-StreamPlayer/components/sdx-StreamPlayer/sdx-StreamPlayer.vue @@ -0,0 +1,110 @@ + + + diff --git a/src/uni_modules/sdx-StreamPlayer/package.json b/src/uni_modules/sdx-StreamPlayer/package.json new file mode 100644 index 00000000..92aa98c1 --- /dev/null +++ b/src/uni_modules/sdx-StreamPlayer/package.json @@ -0,0 +1,109 @@ +{ + "id": "sdx-StreamPlayer", + "displayName": "TTS音频二进制流 PCM边收边播【安卓、iOS、H5】", + "version": "2.0.5", + "description": "流式播放二进制PCM音频文件,实现边收边播", + "keywords": [ + "音频播放,流式数据" +], + "repository": "", + "engines": { + "HBuilderX": "^4.57", + "uni-app": "^4.28", + "uni-app-x": "" + }, + "dcloudext": { + "type": "component-vue", + "sale": { + "regular": { + "price": "0.00" + }, + "sourcecode": { + "price": "0.00" + } + }, + "contact": { + "qq": "" + }, + "declaration": { + "ads": "无", + "data": "无", + "permissions": "文件读写权限" + }, + "npmurl": "", + "darkmode": "x", + "i18n": "x", + "widescreen": "x" + }, + "uni_modules": { + "dependencies": [], + "encrypt": [], + "platforms": { + "cloud": { + "tcb": "√", + "aliyun": "√", + "alipay": "√" + }, + "client": { + "uni-app": { + "vue": { + "vue2": { + "extVersion": "2.0.2", + "minVersion": "" + }, + "vue3": { + "extVersion": "2.0.2", + "minVersion": "" + } + }, + "web": { + "safari": { + "extVersion": "2.0.1", + "minVersion": "" + }, + "chrome": { + "extVersion": "2.0.1", + "minVersion": "" + } + }, + "app": { + "vue": "√", + "nvue": "-", + "android": "√", + "ios": "√", + "harmony": "-" + }, + "mp": { + "weixin": "-", + "alipay": "-", + "toutiao": "-", + "baidu": "-", + "kuaishou": "-", + "jd": "-", + "harmony": "-", + "qq": "-", + "lark": "-" + }, + "quickapp": { + "huawei": "-", + "union": "-" + } + }, + "uni-app-x": { + "web": { + "safari": "-", + "chrome": "-" + }, + "app": { + "android": "-", + "ios": "-", + "harmony": "-" + }, + "mp": { + "weixin": "-" + } + } + } + } + } +} \ No newline at end of file diff --git a/src/uni_modules/sdx-StreamPlayer/plugins/H5AndAndroidStreamPlayer.js b/src/uni_modules/sdx-StreamPlayer/plugins/H5AndAndroidStreamPlayer.js new file mode 100644 index 00000000..e888ea51 --- /dev/null +++ b/src/uni_modules/sdx-StreamPlayer/plugins/H5AndAndroidStreamPlayer.js @@ -0,0 +1,72 @@ +/** + * 二进制音频播放-h5api + * 在app中使用renderjs调用 + * 因为iOS不支持MediaSource,所以暂不支持iOS + */ +export class StreamPlayer { + constructor() { + this.audioContext = new (window.AudioContext || window.webkitAudioContext)() + this.mediaSource = new MediaSource() + this.audio = new Audio() + this.cacheBuffers = [] + this.audio.src = URL.createObjectURL(this.mediaSource) + + this.audioContextConnect() + this.listenMedisSource() + } + + // 连接音频上下文 + audioContextConnect() { + const source = this.audioContext.createMediaElementSource(this.audio) + source.connect(this.audioContext.destination) + } + + // 监听媒体资源 + listenMedisSource() { + this?.mediaSource.addEventListener('sourceopen', () => { + if (this.sourceBuffer) return + this.sourceBuffer = this.mediaSource.addSourceBuffer('audio/mpeg') + this.sourceBuffer.addEventListener('update', () => { + if (this.cacheBuffers?.length && !this.sourceBuffer?.updating) { + const cacheBuffer = this.cacheBuffers.shift() + this.sourceBuffer?.appendBuffer(cacheBuffer) + } + this._pauseAudio() + }) + }) + } + + // 暂停音频 + _pauseAudio() { + const neePlayTime = this.sourceBuffer.timestampOffset - this.audio.currentTime || 0 + this.pauseTimer && clearTimeout(this.pauseTimer) + // 播放完成5秒后还没有新的音频流过来,则暂停音频播放 + this.pauseTimer = setTimeout(() => this.audio.pause(), neePlayTime * 1000 + 5000) + } + _playAudio() { + // 为防止下一段音频流传输过来时,上一段音频已经播放完毕,造成音频卡顿现象, + // 这里做了1秒的延时,可根据实际情况修正 + setTimeout(() => { + if (this.audio.paused) { + try { + this.audio.play() + } catch (e) { + this._playAudio() + } + } + }, 1000) + } + + // 接收音频数据 + appendChunk(audioData) { + if (!audioData?.byteLength) return + + if (this.sourceBuffer?.updating) { + this.cacheBuffers.push(audioData) + } else { + this.sourceBuffer.appendBuffer(audioData) + } + + this._playAudio() + } +} \ No newline at end of file diff --git a/src/uni_modules/sdx-StreamPlayer/plugins/StreamPlayer.js b/src/uni_modules/sdx-StreamPlayer/plugins/StreamPlayer.js new file mode 100644 index 00000000..7e8fcb45 --- /dev/null +++ b/src/uni_modules/sdx-StreamPlayer/plugins/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); + } + } + + _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/uni_modules/sdx-StreamPlayer/readme.md b/src/uni_modules/sdx-StreamPlayer/readme.md new file mode 100644 index 00000000..6252fe97 --- /dev/null +++ b/src/uni_modules/sdx-StreamPlayer/readme.md @@ -0,0 +1,7 @@ +# sdx-StreamPlayer + +最近在调用腾讯的tts进行流式文字转语音实现边收边播放时,遇到了一些问题,查阅很多资料,没有特别系统的解决方案,于是写了个插件,供大家参考。 + +使用方法: +> 核心文件是plugins/StreamPlayer.js 直接复制到你的项目即可。 +> 使用案例在components/sdx-StreamPlayer/sdx-StreamPlayer.vue 直接参照这个写就ok,如果有自己的业务逻辑基于这个调整即可。 \ No newline at end of file diff --git a/src/uni_modules/yao-RecordFrame/changelog.md b/src/uni_modules/yao-RecordFrame/changelog.md new file mode 100644 index 00000000..67dd547c --- /dev/null +++ b/src/uni_modules/yao-RecordFrame/changelog.md @@ -0,0 +1,11 @@ +## 1.0.5(2025-09-03) +新增分贝值 +## 1.0.4(2025-08-30) +加大增益值范围 +## 1.0.3(2025-08-29) +增益 +## 1.0.2(2025-08-29) +添加新属性:增益 +## 1.0.0(2025-08-02) +# yao-RecordFrame +web audio api录音 diff --git a/src/uni_modules/yao-RecordFrame/components/yao-RecordFrame/yao-RecordFrame - 副本.vue b/src/uni_modules/yao-RecordFrame/components/yao-RecordFrame/yao-RecordFrame - 副本.vue new file mode 100644 index 00000000..5e467c7f --- /dev/null +++ b/src/uni_modules/yao-RecordFrame/components/yao-RecordFrame/yao-RecordFrame - 副本.vue @@ -0,0 +1,339 @@ + + + + + \ No newline at end of file diff --git a/src/uni_modules/yao-RecordFrame/components/yao-RecordFrame/yao-RecordFrame.vue b/src/uni_modules/yao-RecordFrame/components/yao-RecordFrame/yao-RecordFrame.vue new file mode 100644 index 00000000..735be302 --- /dev/null +++ b/src/uni_modules/yao-RecordFrame/components/yao-RecordFrame/yao-RecordFrame.vue @@ -0,0 +1,358 @@ + + + + + + \ No newline at end of file diff --git a/src/uni_modules/yao-RecordFrame/package.json b/src/uni_modules/yao-RecordFrame/package.json new file mode 100644 index 00000000..d0633ef5 --- /dev/null +++ b/src/uni_modules/yao-RecordFrame/package.json @@ -0,0 +1,100 @@ +{ + "id": "yao-RecordFrame", + "displayName": "web audio api录音+实时帧回调 支持 ios Android h5", + "version": "1.0.5", + "description": "web audio api录音、实时帧回调、分贝值", + "keywords": [ + "ios", + "Android", + "h5", + "实时帧回调", + "分贝" + ], + "repository": "", + "engines": { + "uni-app": "^4.07", + "uni-app-x": "" + }, + "dcloudext": { + "type": "component-vue", + "sale": { + "regular": { + "price": "0.00" + }, + "sourcecode": { + "price": "0.00" + } + }, + "contact": { + "qq": "3371387322" + }, + "declaration": { + "ads": "无", + "data": "插件不采集任何数据", + "permissions": "需要麦克风权限" + }, + "npmurl": "", + "darkmode": "x", + "i18n": "x", + "widescreen": "x" + }, + "uni_modules": { + "dependencies": [], + "encrypt": [], + "platforms": { + "cloud": { + "tcb": "x", + "aliyun": "x", + "alipay": "x" + }, + "client": { + "uni-app": { + "vue": { + "vue2": "√", + "vue3": "√" + }, + "web": { + "safari": "√", + "chrome": "√" + }, + "app": { + "vue": "√", + "nvue": "-", + "android": "√", + "ios": "√", + "harmony": "x" + }, + "mp": { + "weixin": "x", + "alipay": "x", + "toutiao": "x", + "baidu": "x", + "kuaishou": "x", + "jd": "x", + "harmony": "x", + "qq": "x", + "lark": "x" + }, + "quickapp": { + "huawei": "x", + "union": "x" + } + }, + "uni-app-x": { + "web": { + "safari": "-", + "chrome": "-" + }, + "app": { + "android": "-", + "ios": "-", + "harmony": "-" + }, + "mp": { + "weixin": "-" + } + } + } + } + } +} \ No newline at end of file diff --git a/src/uni_modules/yao-RecordFrame/readme.md b/src/uni_modules/yao-RecordFrame/readme.md new file mode 100644 index 00000000..76eb0c45 --- /dev/null +++ b/src/uni_modules/yao-RecordFrame/readme.md @@ -0,0 +1,78 @@ +# yao-RecordFrame + +##配置 +需要将模块下uni_modules/yao-RecordFrame的dist复制到static目录下面 +或者 +将uni_modules/yao-RecordFrame/dist目录的配置文件放到static/dist目录下面 + +###说明 +插件只适用于接实时语音识别的模型,不适合录音上传 + +### 示例代码 + +```javascript + + + + +``` \ No newline at end of file