Merge branch 'develop' of http://25.13.9.101:9000/K17_AITS/tra-app into develop
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
+202
-190
@@ -1,223 +1,235 @@
|
||||
<template>
|
||||
<view class="ai-chat-container">
|
||||
<scroll-view
|
||||
class="chat-list"
|
||||
scroll-y
|
||||
scroll-bottom="{{scrollBottom}}"
|
||||
:style="{ height: `calc(100vh - ${inputAreaHeight}px)` }"
|
||||
>
|
||||
<view class="chat-item ai">
|
||||
<view class="chat-avatar ai-avatar">
|
||||
<text class="iconfont icon-ai"></text>
|
||||
</view>
|
||||
<view class="chat-content">
|
||||
<view class="content-text">{{ asrResult }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view class="input-area" :style="{ height: inputAreaHeight + 'px' }">
|
||||
<view class="send-btn-area">
|
||||
<button class="hold-send-btn" @touchstart="startRecord" @touchend="stopRecord" @touchcancel="handleTouchEnd">
|
||||
<text class="btn-text">{{ isHolding ? '松开发送' : '按住 说话/发送' }}</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
<view class="container">
|
||||
<button @click="onStartRecord" :disabled="isRecording">开始录音</button>
|
||||
<button @click="onStopRecord" :disabled="!isRecording">停止录音</button>
|
||||
<view class="tip">{{ status }}</view>
|
||||
<view class="tip">当前分贝:{{ currentDecibels }}</view>
|
||||
<yao-RecordFrame ref="recordFrame" @onFrameRecorded="frameRecorded" @currentDecibels="onCurrentDecibels"
|
||||
@onStop="stopIt">
|
||||
</yao-RecordFrame>
|
||||
<sdx-StreamPlayer ref="aaa">xx</sdx-StreamPlayer>
|
||||
<button @click="test">接通电话</button>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// import Recorder from 'recorder-core';
|
||||
import {
|
||||
ProtocolCodec,
|
||||
MessageType,
|
||||
SerializationType,
|
||||
CompressionType,
|
||||
ControlCommand,
|
||||
ProtocolConst,
|
||||
} from './ProtocolCodec';
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
scrollBottom: 0,
|
||||
inputAreaHeight: 80,
|
||||
isHolding: false,
|
||||
asrResult: '',
|
||||
recorderManager: null, // 原生录音管理器
|
||||
ws: null, // WebSocket连接
|
||||
status: "未录音",
|
||||
currentDecibels: 0,
|
||||
isRecording: false,
|
||||
CHUNK_SIZE: 6400, // 200ms/块
|
||||
recordFilePath: '', // 录音文件路径
|
||||
fileReaderTimer: null, // 分片读取定时器
|
||||
recorder: null, // 录音实例
|
||||
audioStream: [], // 实时音频流缓存
|
||||
sampleRate: 16000, // 采样率(常用 16000/44100)
|
||||
bitRate: 16, // 位深
|
||||
channels: 1 // 声道(单声道)
|
||||
ws: null, // WebSocket 实例
|
||||
audioContext: null, // 音频上下文
|
||||
scriptProcessor: null, // 音频处理节点
|
||||
audioBufferSource: null, // 音频源节点
|
||||
frameBufferList: [], // 缓存音频帧
|
||||
// wsUrl: "ws://127.0.0.1:8000/ws/audio", // 替换为实际后端地址
|
||||
wsUrl: "ws://25.64.32.157:9603/trapractice/voiceCallSocket/voiceCall?summary=3D072C9D242C96EA3F0FD647CB14A6F4B4BEA4493BB2FAAB065935FB405071B8A903128E488B6D06BEB23FD4E3D15EE6", // 替换为实际后端地址
|
||||
};
|
||||
},
|
||||
onLoad() {
|
||||
this.initRecorder()
|
||||
},
|
||||
onUnload() {
|
||||
// 页面卸载时销毁实例
|
||||
this.destroyRecorder();
|
||||
// 页面卸载时清理资源
|
||||
this.stopRecordAndClean();
|
||||
},
|
||||
methods: {
|
||||
// 初始化录音器
|
||||
initRecorder() {
|
||||
console.log('plus.audio', plus.audio);
|
||||
|
||||
// 确保 plus.audio 存在
|
||||
if (!plus.audio) {
|
||||
console.error('5+ API 音频模块未加载');
|
||||
uni.showToast({ title: '录音功能初始化失败', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 原初始化逻辑
|
||||
const options = {
|
||||
filename: '_doc/audio/temp_recorder.amr',
|
||||
format: 'amr',
|
||||
sampleRate: 16000,
|
||||
bitRate: 16,
|
||||
channels: 1,
|
||||
bufferSize: 4096,
|
||||
onRecorded: (buffer) => {
|
||||
this.handleRealTimeStream(buffer);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
this.recorder = plus.audio.getRecorder(options);
|
||||
console.log('录音器初始化成功');
|
||||
} catch (e) {
|
||||
console.error('创建录音实例失败:', e);
|
||||
uni.showToast({ title: '录音功能初始化失败', icon: 'none' });
|
||||
}
|
||||
},
|
||||
|
||||
// 开始录音
|
||||
startRecord() {
|
||||
console.log('下·', this.isRecording, !this.recorder);
|
||||
if (this.isRecording || !this.recorder) return;
|
||||
this.isRecording = true;
|
||||
this.audioStream = []; // 清空历史流
|
||||
|
||||
this.recorder.record(
|
||||
() => {
|
||||
console.log('录音开始成功');
|
||||
test() {
|
||||
this.ws = uni.connectSocket({
|
||||
url: this.wsUrl,
|
||||
fail: () => {
|
||||
console.log('fail');
|
||||
},
|
||||
(err) => {
|
||||
console.error('录音启动失败:', err);
|
||||
this.isRecording = false;
|
||||
success: () => {
|
||||
console.log('web');
|
||||
},
|
||||
fail: () => {
|
||||
console.log('fail');
|
||||
},
|
||||
});
|
||||
this.ws.onOpen((res) => {
|
||||
console.log('WebSocket连接已打开', res);
|
||||
this.ws.send({
|
||||
data: ProtocolCodec.pack(MessageType.IDENTITY, {
|
||||
user_id: '1001',
|
||||
token: 'your_auth_token',
|
||||
name: '测试用户'
|
||||
})
|
||||
});
|
||||
});
|
||||
this.ws.onMessage((res) => {
|
||||
const {
|
||||
msgType,
|
||||
body
|
||||
} = ProtocolCodec.unpack(res.data)
|
||||
console.log('msgType', msgType);
|
||||
if (msgType === MessageType.IDENTITY) {
|
||||
console.log('身份校验成功', body);
|
||||
this.onStartRecord()
|
||||
}
|
||||
);
|
||||
})
|
||||
|
||||
},
|
||||
// 申请录音权限
|
||||
async applyRecordPermission() {
|
||||
try {
|
||||
const res = await uni.requestPermissions({
|
||||
scope: "scope.record"
|
||||
});
|
||||
const isGranted = res[0].grantStatus === 1;
|
||||
if (!isGranted) {
|
||||
uni.showToast({
|
||||
title: "请授予录音权限",
|
||||
icon: "none"
|
||||
});
|
||||
}
|
||||
return isGranted;
|
||||
} catch (e) {
|
||||
console.error("申请权限失败:", e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
// ArrayBuffer 转字符串
|
||||
// 兼容的 ArrayBuffer 转字符串方法
|
||||
arrayBufferToStringCompat(buffer) {
|
||||
// 方法1: 使用 String.fromCharCode 和 Uint8Array
|
||||
const uint8Array = new Uint8Array(buffer);
|
||||
let str = '';
|
||||
for (let i = 0; i < uint8Array.length; i++) {
|
||||
str += String.fromCharCode(uint8Array[i]);
|
||||
}
|
||||
return str;
|
||||
|
||||
// 方法2: 或者使用更简洁的方式
|
||||
// return String.fromCharCode.apply(null, new Uint8Array(buffer));
|
||||
},
|
||||
// 开始录音
|
||||
onStartRecord() {
|
||||
console.log('开始录音');
|
||||
try {
|
||||
this.$refs.recordFrame.start({
|
||||
sampleRate: 16000,
|
||||
frameSize: 1024,
|
||||
gain: 1.0,
|
||||
onFrameRecorded: ({
|
||||
isLastFrame,
|
||||
frameBuffer
|
||||
}) => {
|
||||
try{
|
||||
this.ws.send({
|
||||
data: ProtocolCodec.pack(MessageType.AUDIO_DATA, frameBuffer)
|
||||
});
|
||||
} catch(e){}
|
||||
},
|
||||
onDecibels: (decibels) => {
|
||||
this.currentDecibels = decibels;
|
||||
}
|
||||
});
|
||||
this.isRecording = true;
|
||||
this.status = "录音中...";
|
||||
} catch (e) {
|
||||
console.error("启动录音失败:", e);
|
||||
this.status = "启动录音失败";
|
||||
this.isRecording = false;
|
||||
}
|
||||
},
|
||||
|
||||
// 停止录音
|
||||
stopRecord() {
|
||||
if (!this.isRecording || !this.recorder) return;
|
||||
this.isRecording = false;
|
||||
onStopRecord() {
|
||||
this.stopRecordAndClean();
|
||||
},
|
||||
|
||||
this.recorder.stop(
|
||||
(filePath) => {
|
||||
console.log('录音停止,文件路径:', filePath);
|
||||
// 最终完整文件(可选)
|
||||
this.handleFinalFile(filePath);
|
||||
},
|
||||
(err) => {
|
||||
console.error('录音停止失败:', err);
|
||||
// 停止录音并清理资源
|
||||
stopRecordAndClean() {
|
||||
if (this.isRecording) {
|
||||
// 停止录音组件
|
||||
this.$refs.recordFrame.stop();
|
||||
this.isRecording = false;
|
||||
this.status = "已停止录音";
|
||||
}
|
||||
|
||||
// 关闭 WebSocket
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
// 清理音频上下文
|
||||
if (this.audioContext) {
|
||||
this.audioContext.close();
|
||||
this.audioContext = null;
|
||||
this.scriptProcessor = null;
|
||||
this.frameBufferList = [];
|
||||
}
|
||||
|
||||
// 重置状态
|
||||
this.currentDecibels = 0;
|
||||
},
|
||||
|
||||
// 接收音频帧并处理
|
||||
frameRecorded({
|
||||
isLastFrame,
|
||||
frameBuffer
|
||||
}) {
|
||||
// console.log("收到音频帧:", isLastFrame, frameBuffer.length);
|
||||
|
||||
// 2. 通过 WebSocket 发送给后端
|
||||
if (this.ws) {
|
||||
try {
|
||||
this.ws.send({
|
||||
data: frameBuffer
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("发送音频帧失败:", e);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
},
|
||||
|
||||
// 监听分贝值
|
||||
onCurrentDecibels(decibels) {
|
||||
// this.currentDecibels = decibels.toFixed(2);
|
||||
// console.log("当前分贝:", this.currentDecibels);
|
||||
},
|
||||
|
||||
// 录音停止回调
|
||||
stopIt(base64) {
|
||||
this.stopRecordAndClean();
|
||||
console.log("录音停止,最终音频Base64:", base64?.substring(0, 50) + "...");
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ai-chat-container {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
background-color: #f5f7fa;
|
||||
box-sizing: border-box;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
.container {
|
||||
padding: 20rpx;
|
||||
}
|
||||
|
||||
.chat-list {
|
||||
width: 100%;
|
||||
padding: 20rpx 16rpx;
|
||||
box-sizing: border-box;
|
||||
button {
|
||||
margin: 10rpx 0;
|
||||
padding: 15rpx 30rpx;
|
||||
background: #007aff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.chat-item {
|
||||
display: flex;
|
||||
margin-bottom: 30rpx;
|
||||
max-width: 85%;
|
||||
button:disabled {
|
||||
background: #ccc;
|
||||
}
|
||||
|
||||
.ai-avatar {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
margin-right: 20rpx;
|
||||
background-color: #9f7aea;
|
||||
}
|
||||
|
||||
.icon-ai {
|
||||
font-size: 40rpx;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.chat-content .content-text {
|
||||
padding: 24rpx 28rpx;
|
||||
border-radius: 20rpx 20rpx 20rpx 8rpx;
|
||||
line-height: 1.6;
|
||||
font-size: 32rpx;
|
||||
.tip {
|
||||
margin: 15rpx 0;
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
word-wrap: break-word;
|
||||
word-break: break-all;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.input-area {
|
||||
width: 100%;
|
||||
background-color: #fff;
|
||||
padding: 16rpx 20rpx;
|
||||
box-sizing: border-box;
|
||||
border-top: 1rpx solid #eee;
|
||||
}
|
||||
|
||||
.send-btn-area {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.hold-send-btn {
|
||||
width: 80%;
|
||||
height: 90rpx;
|
||||
line-height: 90rpx;
|
||||
border-radius: 45rpx;
|
||||
background-color: #4299e1;
|
||||
color: #fff;
|
||||
font-size: 34rpx;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 4rpx 12rpx rgba(66, 153, 225, 0.3);
|
||||
transition: all 0.2s ease;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hold-send-btn:active {
|
||||
background-color: #3182ce;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.iconfont {
|
||||
font-family: 'iconfont' !important;
|
||||
font-style: normal;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
.icon-ai::before {
|
||||
content: '\e602';
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -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中播放二进制音频流
|
||||
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<button type="default" @click="connect">播放音频</button>
|
||||
<button type="default" @click="end">结束播放</button>
|
||||
<!-- 逻辑层和renderjs的交互需要通过这种方式传递数据,具体看文档 -->
|
||||
<view :isStop="isStop" :change:isStop="renderJS.stop" :prop="currBuffer" :change:prop="renderJS.playTTS" type="renderjs" module="renderJS"></view>
|
||||
</template>
|
||||
<script>
|
||||
/**
|
||||
* 本页面代码为仅供参考的案例,只列举了实现思路,是我从项目代码中抽出来的,具体是否能跑通需要测试
|
||||
* 核心代码在plugins
|
||||
*/
|
||||
|
||||
let ws = null
|
||||
export default {
|
||||
data(){
|
||||
return {
|
||||
currBuffer: null,
|
||||
isStop: false,
|
||||
isStreamPlaying: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
connect() {
|
||||
this.ws = uni.connectSocket({
|
||||
url: 'ws://172.16.89.58:8000/ws/audio',
|
||||
complete: () => {
|
||||
console.log('complete');
|
||||
},
|
||||
success: () => {
|
||||
console.log('web');
|
||||
this.ws.onOpen((res) => {
|
||||
console.log('WebSocket连接已打开', res);
|
||||
this.ws.onMessage((res) => {
|
||||
let messageData = res.data;
|
||||
// 处理不同类型的数据
|
||||
if (typeof messageData === 'string') {
|
||||
// 文本数据
|
||||
try {
|
||||
const parsedData = JSON.parse(messageData);
|
||||
this.handleMessage(parsedData);
|
||||
} catch (e) {
|
||||
this.handleMessage(messageData);
|
||||
}
|
||||
} else if (messageData instanceof ArrayBuffer) {
|
||||
// 二进制数据
|
||||
this.isStreamPlaying = true
|
||||
this.currBuffer= uni.arrayBufferToBase64(messageData)
|
||||
try {
|
||||
// const str = this.arrayBufferToStringCompat(messageData);
|
||||
// console.log('转换后的字符串:', str);
|
||||
} catch (e) {
|
||||
console.log('二进制数据解析失败:', e);
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
}})
|
||||
},
|
||||
end() {
|
||||
this.isStop = !this.isStop
|
||||
},
|
||||
changeStreamPlaying(e) {
|
||||
this.isStreamPlaying = e.type
|
||||
},
|
||||
appendBuffer(messageData) {
|
||||
this.isStreamPlaying = true
|
||||
this.currBuffer= uni.arrayBufferToBase64(messageData)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script module="renderJS" lang="renderjs">
|
||||
/**
|
||||
* 如果不需要支持iOS,建议使用这个,同时支持mp3和pcm
|
||||
*/
|
||||
// import { StreamPlayer } from "../../plugins/H5AndAndroidStreamPlayer";
|
||||
/**
|
||||
* 如果需要支持安卓iOS和H5,使用这个,支持pcm,暂时不支持mp3
|
||||
* 如果想支持mp3可以自己开发,我在测试中mp3的效果太差所以放弃了。
|
||||
*/
|
||||
import { StreamPlayer } from "../../plugins/StreamPlayer";
|
||||
let player=null
|
||||
export default {
|
||||
mounted() {
|
||||
const _this = this;
|
||||
player = new StreamPlayer({callback: _this.callback});
|
||||
},
|
||||
methods: {
|
||||
playTTS (base64) {
|
||||
console.log('base64', base64);
|
||||
if(!base64)return
|
||||
const binaryStr = atob(base64)
|
||||
const bytes = new Uint8Array(binaryStr.length)
|
||||
for (let i = 0; i < binaryStr.length; i++) {
|
||||
bytes[i] = binaryStr.charCodeAt(i)
|
||||
}
|
||||
player.appendChunk(bytes.buffer);
|
||||
},
|
||||
stop() {
|
||||
player&&player.destroy()
|
||||
player = null
|
||||
},
|
||||
callback(e) {
|
||||
if(e==='ended') {
|
||||
this.$ownerInstance.callMethod('changeStreamPlaying', {type: false});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -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": "-"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# sdx-StreamPlayer
|
||||
|
||||
最近在调用腾讯的tts进行流式文字转语音实现边收边播放时,遇到了一些问题,查阅很多资料,没有特别系统的解决方案,于是写了个插件,供大家参考。
|
||||
|
||||
使用方法:
|
||||
> 核心文件是plugins/StreamPlayer.js 直接复制到你的项目即可。
|
||||
> 使用案例在components/sdx-StreamPlayer/sdx-StreamPlayer.vue 直接参照这个写就ok,如果有自己的业务逻辑基于这个调整即可。
|
||||
@@ -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录音
|
||||
@@ -0,0 +1,339 @@
|
||||
<template>
|
||||
<view
|
||||
:options="options"
|
||||
:change:options="record.startRecord"
|
||||
:status="status"
|
||||
:change:status="record.onStop"
|
||||
>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
const options = ref(null);
|
||||
const status = ref(null);
|
||||
|
||||
// 对外暴露方法(替代 export default 的 methods)
|
||||
defineExpose({
|
||||
start: startRecord,
|
||||
stop: stopRecord
|
||||
});
|
||||
|
||||
/**
|
||||
* 启动录音
|
||||
* @param {Object} option - 录音配置
|
||||
* @param {Function} option.onFrameRecorded - 帧数据回调(高频)
|
||||
* @param {Function} option.onDecibels - 分贝回调(高频)
|
||||
* @param {number} option.sampleRate - 采样率
|
||||
* @param {number} option.frameSize - 帧大小
|
||||
* @param {number} option.gain - 增益
|
||||
*/
|
||||
function startRecord(option) {
|
||||
// 校验回调函数
|
||||
if (option.onFrameRecorded && typeof option.onFrameRecorded !== 'function') {
|
||||
console.error('onFrameRecorded 必须是函数');
|
||||
option.onFrameRecorded = null;
|
||||
}
|
||||
if (option.onDecibels && typeof option.onDecibels !== 'function') {
|
||||
console.error('onDecibels 必须是函数');
|
||||
option.onDecibels = null;
|
||||
}
|
||||
options.value = option;
|
||||
status.value = 'start';
|
||||
}
|
||||
|
||||
// 停止录音
|
||||
function stopRecord() {
|
||||
status.value = 'stop';
|
||||
options.value = null;
|
||||
}
|
||||
|
||||
// base64 转 Uint8Array(备用,若业务需要)
|
||||
function base64ToUint8Array(base64) {
|
||||
const binaryString = atob(base64.split(',')[1] || base64);
|
||||
const len = binaryString.length;
|
||||
const bytes = new Uint8Array(len);
|
||||
for (let i = 0; i < len; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
// 提示弹窗
|
||||
function toShowToast() {
|
||||
// uniShowToast({
|
||||
// title: '发生错误,请检查是否有麦克风权限',
|
||||
// icon: 'none'
|
||||
// });
|
||||
stopRecord();
|
||||
}
|
||||
|
||||
// 录音停止回调(低频,转发 $emit)
|
||||
function recordedChunks(base64) {
|
||||
// Vue3 触发自定义事件(替代 this.$emit)
|
||||
emit('onStop', base64);
|
||||
}
|
||||
|
||||
// Vue3 声明自定义事件(替代 props 中的 emits)
|
||||
const emit = defineEmits(['onStop']);
|
||||
</script>
|
||||
|
||||
<script module="record" lang="renderjs">
|
||||
// 保存需要关闭的引用
|
||||
let mediaStream;
|
||||
let audioContext;
|
||||
let processor;
|
||||
// 全局变量存储录音数据
|
||||
let recordedChunks = [];
|
||||
let decibelHistory = [];
|
||||
|
||||
// 分贝计算相关配置
|
||||
const DB_CONFIG = {
|
||||
minDecibels: -80,
|
||||
maxDecibels: -30,
|
||||
smoothingTimeConstant: 0.8
|
||||
};
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
async startRecord(options) {
|
||||
|
||||
if (!options) return;
|
||||
if (audioContext) return;
|
||||
console.log(typeof options.onFrameRecorded);
|
||||
// 缓存高频回调函数(核心优化)
|
||||
this.currentCallbacks = {
|
||||
onFrameRecorded: options.onFrameRecorded,
|
||||
onDecibels: options.onDecibels
|
||||
};
|
||||
|
||||
try {
|
||||
// 配置参数校验
|
||||
if (options.gain < 1.0) options.gain = 1.0;
|
||||
if (options.gain > 20.0) options.gain = 20.0;
|
||||
|
||||
// Vue3 + 多端兼容:AudioContext 初始化
|
||||
audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||
if (audioContext.state === 'suspended') {
|
||||
await audioContext.resume();
|
||||
}
|
||||
|
||||
// 加载 AudioWorklet(注意路径适配 uni-app 静态资源)
|
||||
await audioContext.audioWorklet.addModule('static/dist/processor.worklet.js');
|
||||
|
||||
// 权限检测 + 获取麦克风流
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
this.$ownerInstance.callMethod('toShowToast');
|
||||
return;
|
||||
}
|
||||
mediaStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
sampleRate: options.sampleRate,
|
||||
channelCount: 1,
|
||||
echoCancellation: true, // 可选:关闭回声消除(根据业务需求)
|
||||
noiseSuppression: true // 可选:关闭降噪
|
||||
}
|
||||
});
|
||||
|
||||
// 初始化音频节点
|
||||
const source = audioContext.createMediaStreamSource(mediaStream);
|
||||
processor = new AudioWorkletNode(audioContext, 'processor-worklet');
|
||||
|
||||
// 初始化处理器参数
|
||||
processor.port.postMessage({
|
||||
type: 'init',
|
||||
data: {
|
||||
frameSize: options.frameSize,
|
||||
fromSampleRate: 48000,
|
||||
toSampleRate: options.sampleRate,
|
||||
arrayBufferType: 'short16',
|
||||
gain: options.gain
|
||||
}
|
||||
});
|
||||
|
||||
// 接收音频帧数据(核心:直接调用回调)
|
||||
processor.port.onmessage = (t) => {
|
||||
const data = t.data;
|
||||
const frameBuffer = data.frameBuffer;
|
||||
const isLastFrame = data.isLastFrame;
|
||||
|
||||
// 1. 计算分贝并触发高频回调
|
||||
if (frameBuffer && frameBuffer.byteLength > 0) {
|
||||
const decibels = this.calculateDecibels(frameBuffer);
|
||||
this.onDecibelsCalculated(decibels); // 触发分贝回调
|
||||
}
|
||||
|
||||
// 2. 处理帧数据并触发高频回调
|
||||
if (frameBuffer && frameBuffer.byteLength) {
|
||||
const frameSize = options.frameSize;
|
||||
for (let offset = 0; offset < frameBuffer.byteLength; offset += frameSize) {
|
||||
const sliceBuffer = frameBuffer.slice(offset, offset + frameSize);
|
||||
const frameData = {
|
||||
isLastFrame: isLastFrame && offset + frameSize >= frameBuffer.byteLength,
|
||||
frameBuffer: this.toBase64(sliceBuffer),
|
||||
frameBufferUint8: new Uint8Array(sliceBuffer) // 直接返回 Uint8Array,减少转码
|
||||
};
|
||||
|
||||
this.onFrameRecorded(frameData);
|
||||
|
||||
// 存储录音数据
|
||||
recordedChunks.push(sliceBuffer);
|
||||
}
|
||||
} else {
|
||||
this.onFrameRecorded(t.data);
|
||||
}
|
||||
};
|
||||
|
||||
// 连接音频节点
|
||||
source.connect(processor);
|
||||
processor.connect(audioContext.destination);
|
||||
} catch (err) {
|
||||
console.error('录音初始化失败:', err);
|
||||
this.$ownerInstance.callMethod('toShowToast');
|
||||
}
|
||||
},
|
||||
|
||||
// 分贝计算(优化后逻辑)
|
||||
calculateDecibels(frameBuffer) {
|
||||
try {
|
||||
const samples = new Int16Array(frameBuffer);
|
||||
let sum = 0;
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
const value = samples[i] / 32768; // 归一化到 [-1, 1]
|
||||
sum += value * value;
|
||||
}
|
||||
const rms = Math.sqrt(sum / samples.length);
|
||||
if (rms < 0.00001) return DB_CONFIG.minDecibels;
|
||||
|
||||
let db = 20 * Math.log10(rms);
|
||||
// 平滑处理
|
||||
if (decibelHistory.length > 0) {
|
||||
const lastDb = decibelHistory[decibelHistory.length - 1];
|
||||
db = lastDb * DB_CONFIG.smoothingTimeConstant + db * (1 - DB_CONFIG.smoothingTimeConstant);
|
||||
}
|
||||
// 限制范围 + 维护历史
|
||||
db = Math.max(DB_CONFIG.minDecibels, Math.min(DB_CONFIG.maxDecibels, db));
|
||||
decibelHistory.push(db);
|
||||
if (decibelHistory.length > 100) decibelHistory.shift();
|
||||
|
||||
return db;
|
||||
} catch (e) {
|
||||
console.error('分贝计算错误:', e);
|
||||
return DB_CONFIG.minDecibels;
|
||||
}
|
||||
},
|
||||
|
||||
// Base64 转换(增加空值保护)
|
||||
toBase64(buffer) {
|
||||
if (!buffer || buffer.byteLength === 0) return '';
|
||||
let binary = '';
|
||||
const bytes = new Uint8Array(buffer);
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return window.btoa(binary);
|
||||
},
|
||||
|
||||
// 停止录音逻辑
|
||||
async onStop(value) {
|
||||
if (value !== 'stop') return;
|
||||
|
||||
this.onFrameRecorded({
|
||||
isLastFrame: true,
|
||||
frameBuffer: ''
|
||||
});
|
||||
|
||||
this.onRecordedChunks(recordedChunks);
|
||||
|
||||
|
||||
|
||||
// 清理资源(Vue3 内存管理重点)
|
||||
recordedChunks = [];
|
||||
decibelHistory = [];
|
||||
this.currentCallbacks = { onFrameRecorded: null, onDecibels: null };
|
||||
|
||||
if (mediaStream) {
|
||||
mediaStream.getTracks().forEach(track => track.stop());
|
||||
mediaStream = null;
|
||||
}
|
||||
if (processor) {
|
||||
processor.disconnect();
|
||||
processor.port.close(); // 关闭消息端口
|
||||
processor = null;
|
||||
}
|
||||
if (audioContext) {
|
||||
await audioContext.close().catch(err => console.error('关闭 AudioContext 失败:', err));
|
||||
audioContext = null;
|
||||
}
|
||||
},
|
||||
|
||||
// 合并音频 Buffer
|
||||
mergeAudioBuffers(buffers) {
|
||||
const totalLength = buffers.reduce((acc, buf) => acc + buf.byteLength, 0);
|
||||
const result = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
buffers.forEach(buffer => {
|
||||
result.set(new Uint8Array(buffer), offset);
|
||||
offset += buffer.byteLength;
|
||||
});
|
||||
return result.buffer;
|
||||
},
|
||||
|
||||
// 创建 WAV 文件(修复 Vue3 端的字节序问题)
|
||||
createWavBlob(pcmData, numChannels, sampleRate) {
|
||||
const bytesPerSample = 2;
|
||||
const blockAlign = numChannels * bytesPerSample;
|
||||
const byteRate = sampleRate * blockAlign;
|
||||
const bufferLength = pcmData.byteLength;
|
||||
const totalLength = 44 + bufferLength;
|
||||
|
||||
const buffer = new ArrayBuffer(totalLength);
|
||||
const view = new DataView(buffer);
|
||||
|
||||
// 写入 WAV 头
|
||||
this.writeString(view, 0, 'RIFF');
|
||||
view.setUint32(4, totalLength - 8, true);
|
||||
this.writeString(view, 8, 'WAVE');
|
||||
this.writeString(view, 12, 'fmt ');
|
||||
view.setUint32(16, 16, true); // PCM 格式
|
||||
view.setUint16(20, 1, true);
|
||||
view.setUint16(22, numChannels, true);
|
||||
view.setUint32(24, sampleRate, true);
|
||||
view.setUint32(28, byteRate, true);
|
||||
view.setUint16(32, blockAlign, true);
|
||||
view.setUint16(34, 16, true); // 16 位采样
|
||||
this.writeString(view, 36, 'data');
|
||||
view.setUint32(40, bufferLength, true);
|
||||
|
||||
// 写入 PCM 数据(Vue3 多端兼容)
|
||||
const pcm8 = new Uint8Array(pcmData);
|
||||
for (let i = 0; i < pcm8.length; i++) {
|
||||
view.setUint8(44 + i, pcm8[i]);
|
||||
}
|
||||
|
||||
return new Blob([buffer], { type: 'audio/wav' });
|
||||
},
|
||||
|
||||
// Blob 转 Base64
|
||||
blobToBase64(blob) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => resolve(reader.result);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
},
|
||||
|
||||
// 辅助:写入字符串到 DataView
|
||||
writeString(view, offset, string) {
|
||||
for (let i = 0; i < string.length; i++) {
|
||||
view.setUint8(offset + i, string.charCodeAt(i));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,358 @@
|
||||
<template>
|
||||
<view :options="options" :change:options="record.startRecord" :status="status" :change:status="record.onStop">
|
||||
</view>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
options: null,
|
||||
status: null,
|
||||
onFrameRecorded: ()=>{},
|
||||
onDecibels: ()=>{},
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
start(option) {
|
||||
this.options = option;
|
||||
this.status = 'start';
|
||||
if (option.onFrameRecorded && typeof option.onFrameRecorded === 'function') {
|
||||
this.onFrameRecorded = option.onFrameRecorded
|
||||
}
|
||||
if (option.onDecibels && typeof option.onDecibels === 'function') {
|
||||
this.onDecibels = option.onDecibels
|
||||
}
|
||||
},
|
||||
stop() {
|
||||
this.status = 'stop';
|
||||
this.options = null;
|
||||
},
|
||||
frameRecorded({
|
||||
isLastFrame,
|
||||
frameBuffer
|
||||
}) {
|
||||
// console.log('1111',typeof this.onFrameRecorded);
|
||||
this.onFrameRecorded({
|
||||
isLastFrame,
|
||||
frameBuffer: this.base64ToUint8Array(frameBuffer)
|
||||
})
|
||||
// this.$emit('onFrameRecorded', {
|
||||
// isLastFrame,
|
||||
// frameBuffer: this.base64ToUint8Array(frameBuffer)
|
||||
// })
|
||||
},
|
||||
decibels(value) {
|
||||
this.onDecibels(value)
|
||||
|
||||
// this.$emit('currentDecibels', value);
|
||||
},
|
||||
base64ToUint8Array(base64) {
|
||||
const binaryString = atob(base64.split(',')[1] || base64);
|
||||
const len = binaryString.length;
|
||||
const bytes = new Uint8Array(len);
|
||||
|
||||
for (let i = 0; i < len; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
|
||||
return bytes;
|
||||
},
|
||||
toShowToast() {
|
||||
uni.showToast({
|
||||
title: '发生错误,请检查是否有麦克风权限',
|
||||
icon: 'none'
|
||||
});
|
||||
this.stop();
|
||||
},
|
||||
recordedChunks(base64) {
|
||||
this.$emit('onStop', base64)
|
||||
|
||||
},
|
||||
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script module="record" lang="renderjs">
|
||||
// 保存需要关闭的引用
|
||||
let mediaStream;
|
||||
let audioContext;
|
||||
let processor;
|
||||
// 全局变量存储录音数据
|
||||
let recordedChunks = [];
|
||||
let decibelHistory = []; // 存储分贝历史数据用于可视化
|
||||
|
||||
// 分贝计算相关配置
|
||||
const DB_CONFIG = {
|
||||
minDecibels: -80, // 最小可测分贝
|
||||
maxDecibels: -30, // 最大可测分贝
|
||||
smoothingTimeConstant: 0.8 // 平滑系数,使分贝变化更平缓
|
||||
};
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async startRecord(options) {
|
||||
if (options == null) return;
|
||||
|
||||
if (audioContext) return;
|
||||
|
||||
try {
|
||||
// 配置参数
|
||||
var a, i = options.sampleRate,
|
||||
s = options.frameSize;
|
||||
if (options.gain < 1.0) {
|
||||
options.gain = 1.0;
|
||||
}
|
||||
if (options.gain > 20.0) {
|
||||
options.gain = 20.0;
|
||||
}
|
||||
|
||||
audioContext = new AudioContext();
|
||||
|
||||
// 加载并初始化AudioWorklet
|
||||
await audioContext.audioWorklet.addModule('static/dist/processor.worklet.js');
|
||||
|
||||
//const mediaStream = new MediaStream();
|
||||
mediaStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
sampleRate: options.sampleRate,
|
||||
channelCount: 1
|
||||
}
|
||||
});
|
||||
|
||||
const source = audioContext.createMediaStreamSource(mediaStream);
|
||||
processor = new AudioWorkletNode(audioContext, 'processor-worklet');
|
||||
|
||||
// 初始化处理器
|
||||
processor.port.postMessage({
|
||||
type: 'init',
|
||||
data: {
|
||||
frameSize: options.frameSize, // 样本数 (1280字节 / 2字节每样本)
|
||||
fromSampleRate: 48000, // 输入采样率
|
||||
toSampleRate: options.sampleRate, // 输出采样率 (1/3)
|
||||
arrayBufferType: 'short16',
|
||||
gain: options.gain
|
||||
}
|
||||
});
|
||||
|
||||
// 接收40ms间隔的音频数据
|
||||
processor.port.onmessage = (t) => {
|
||||
|
||||
var r = t.data,
|
||||
o = r.frameBuffer,
|
||||
n = r.isLastFrame;
|
||||
|
||||
// 计算当前帧的分贝值
|
||||
if (o && o.byteLength > 0) {
|
||||
const decibels = this.calculateDecibels(o);
|
||||
this.onDecibelsCalculated(decibels); // 触发分贝回调
|
||||
|
||||
// 存储历史数据,限制长度
|
||||
decibelHistory.push(decibels);
|
||||
if (decibelHistory.length > 100) {
|
||||
decibelHistory.shift();
|
||||
}
|
||||
}
|
||||
|
||||
if (null == o ? void 0 : o.byteLength)
|
||||
for (var a = 0; a < o.byteLength;) {
|
||||
const frameData = {
|
||||
isLastFrame: n && a + s >= o.byteLength,
|
||||
frameBuffer: t.data.frameBuffer.slice(a, a + s)
|
||||
};
|
||||
this.onFrameRecorded(frameData);
|
||||
|
||||
// 存储录音数据(仅在非暂停状态)
|
||||
recordedChunks.push(frameData.frameBuffer);
|
||||
|
||||
a += s;
|
||||
}
|
||||
else this.onFrameRecorded(t.data);
|
||||
|
||||
};
|
||||
|
||||
source.connect(processor);
|
||||
processor.connect(audioContext.destination);
|
||||
} catch (err) {
|
||||
this.$ownerInstance.callMethod('toShowToast');
|
||||
|
||||
}
|
||||
|
||||
|
||||
},
|
||||
onFrameRecorded({
|
||||
isLastFrame,
|
||||
frameBuffer
|
||||
}) {
|
||||
this.$ownerInstance.callMethod('frameRecorded', {
|
||||
isLastFrame,
|
||||
frameBuffer: this.toBase64(frameBuffer)
|
||||
});
|
||||
},
|
||||
calculateDecibels(frameBuffer) {
|
||||
try {
|
||||
// 将帧数据转换为16位整数数组
|
||||
const samples = new Int16Array(frameBuffer);
|
||||
|
||||
// 计算均方根(RMS)
|
||||
let sum = 0;
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
const value = samples[i] / 32768; // 归一化到[-1, 1]范围
|
||||
sum += value * value; // 平方和
|
||||
}
|
||||
|
||||
const rms = Math.sqrt(sum / samples.length);
|
||||
|
||||
// 防止log(0)错误
|
||||
if (rms < 0.00001) {
|
||||
return DB_CONFIG.minDecibels;
|
||||
}
|
||||
|
||||
// 转换为分贝 (20 * log10(rms))
|
||||
let db = 20 * Math.log10(rms);
|
||||
|
||||
// 应用平滑处理
|
||||
if (decibelHistory.length > 0) {
|
||||
const lastDb = decibelHistory[decibelHistory.length - 1];
|
||||
db = lastDb * DB_CONFIG.smoothingTimeConstant + db * (1 - DB_CONFIG.smoothingTimeConstant);
|
||||
}
|
||||
|
||||
// 限制分贝范围
|
||||
return Math.max(DB_CONFIG.minDecibels, Math.min(DB_CONFIG.maxDecibels, db));
|
||||
} catch (e) {
|
||||
console.error('计算分贝时出错:', e);
|
||||
return DB_CONFIG.minDecibels;
|
||||
}
|
||||
},
|
||||
onDecibelsCalculated(decibels) {
|
||||
//console.log(`当前分贝: ${decibels.toFixed(1)} dB`);
|
||||
this.$ownerInstance.callMethod('decibels', decibels.toFixed(1));
|
||||
// 可以在这里添加分贝可视化逻辑
|
||||
// 例如更新UI显示当前音量
|
||||
},
|
||||
toBase64(buffer) {
|
||||
let binary = "";
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const len = bytes.byteLength;
|
||||
for (let i = 0; i < len; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return window.btoa(binary);
|
||||
},
|
||||
async onRecordedChunks(chunks) {
|
||||
|
||||
var mergedBuffer = this.mergeAudioBuffers(chunks);
|
||||
// 2. 将合并的二进制流转换为WAV格式(需要添加WAV文件头)
|
||||
const wavBlob = this.createWavBlob(mergedBuffer, 1, 16000); // 单声道,44100Hz
|
||||
// 3. 将WAV转为Base64(可选,若需传输)
|
||||
const base64 = await this.blobToBase64(wavBlob);
|
||||
this.$ownerInstance.callMethod('recordedChunks', base64)
|
||||
},
|
||||
onStop(value) {
|
||||
if (value !== 'stop') return;
|
||||
|
||||
|
||||
this.onFrameRecorded({
|
||||
isLastFrame: true,
|
||||
frameBuffer: ''
|
||||
});
|
||||
|
||||
this.onRecordedChunks(recordedChunks);
|
||||
|
||||
recordedChunks = [];
|
||||
|
||||
if (mediaStream) {
|
||||
// 停止所有媒体轨道
|
||||
mediaStream.getTracks().forEach(track => track.stop());
|
||||
mediaStream = null;
|
||||
}
|
||||
|
||||
if (processor) {
|
||||
// 断开音频节点连接
|
||||
processor.disconnect();
|
||||
processor = null;
|
||||
}
|
||||
|
||||
if (audioContext) {
|
||||
// 关闭音频上下文
|
||||
audioContext.close().then(() => {
|
||||
audioContext = null;
|
||||
});
|
||||
}
|
||||
},
|
||||
//合并所有buffer
|
||||
mergeAudioBuffers(buffers) {
|
||||
let totalLength = buffers.reduce((acc, buf) => acc + buf.byteLength, 0);
|
||||
const result = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
buffers.forEach(buffer => {
|
||||
result.set(new Uint8Array(buffer), offset);
|
||||
offset += buffer.byteLength;
|
||||
});
|
||||
// 验证合并后的长度是否正确
|
||||
if (offset !== totalLength) console.error("合并后的长度不符!");
|
||||
return result.buffer;
|
||||
},
|
||||
// 新增WAV封装函数(基于前序回答的createWavBlob)
|
||||
createWavBlob(pcmData, numChannels, sampleRate) {
|
||||
const bytesPerSample = 2; // 16-bit PCM
|
||||
const blockAlign = numChannels * bytesPerSample;
|
||||
const byteRate = sampleRate * blockAlign;
|
||||
const bufferLength = pcmData.byteLength;
|
||||
const totalLength = 44 + bufferLength; // WAV头(44字节) + 音频数据
|
||||
|
||||
const buffer = new ArrayBuffer(totalLength);
|
||||
const view = new DataView(buffer);
|
||||
|
||||
// 写入WAV文件头(RIFF、fmt、data区块)
|
||||
this.writeString(view, 0, 'RIFF');
|
||||
view.setUint32(4, totalLength - 8, true);
|
||||
this.writeString(view, 8, 'WAVE');
|
||||
this.writeString(view, 12, 'fmt ');
|
||||
view.setUint32(16, 16, true); // fmt区块大小
|
||||
view.setUint16(20, 1, true); // PCM格式
|
||||
view.setUint16(22, numChannels, true);
|
||||
view.setUint32(24, sampleRate, true);
|
||||
view.setUint32(28, byteRate, true);
|
||||
view.setUint16(32, blockAlign, true);
|
||||
view.setUint16(34, 16, true); // 16位采样
|
||||
this.writeString(view, 36, 'data');
|
||||
view.setUint32(40, bufferLength, true);
|
||||
|
||||
// 写入音频数据(假设pcmData是Uint8Array,需转换为16位PCM)
|
||||
const pcm16 = new Int16Array(pcmData);
|
||||
for (let i = 0; i < pcm16.length; i++) {
|
||||
view.setInt16(44 + i * 2, pcm16[i], true);
|
||||
}
|
||||
|
||||
return new Blob([buffer], {
|
||||
type: 'audio/wav'
|
||||
});
|
||||
},
|
||||
// 辅助函数:Blob转Base64
|
||||
blobToBase64(blob) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => resolve(reader.result);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
},
|
||||
// 辅助函数:字符串写入DataView
|
||||
writeString(view, offset, string) {
|
||||
for (let i = 0; i < string.length; i++) {
|
||||
view.setUint8(offset + i, string.charCodeAt(i));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
</style>
|
||||
@@ -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": "-"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
# yao-RecordFrame
|
||||
|
||||
##配置
|
||||
需要将模块下uni_modules/yao-RecordFrame的dist复制到static目录下面
|
||||
或者
|
||||
将uni_modules/yao-RecordFrame/dist目录的配置文件放到static/dist目录下面
|
||||
|
||||
###说明
|
||||
插件只适用于接实时语音识别的模型,不适合录音上传
|
||||
|
||||
### 示例代码
|
||||
|
||||
```javascript
|
||||
<template>
|
||||
<view class="content">
|
||||
<view class="start-record" @click="onStartRecord">开始录音</view>
|
||||
<view class="stop-record" @click="onStopRecord">停止录音</view>
|
||||
|
||||
<view class="frame">
|
||||
<view>实时回调:是否是最后一帧:{{isLastFrame}}</view>
|
||||
<view class="frameBuffer">{{frameBuffer}}</view>
|
||||
</view>
|
||||
|
||||
<yao-RecordFrame
|
||||
ref="recordFrame"
|
||||
@onFrameRecorded="frameRecorded"
|
||||
@currentDecibels="onCurrentDecibels"
|
||||
@onStop="stopIt"></yao-RecordFrame>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
frameBuffer:'',//实时帧的frameBuffer值
|
||||
isLastFrame:false,//是否是最后一帧
|
||||
}
|
||||
},
|
||||
onLoad() {
|
||||
|
||||
},
|
||||
methods: {
|
||||
onStartRecord(){
|
||||
//开启录音(仅支持两种参数)
|
||||
this.$refs.recordFrame.start({
|
||||
sampleRate:16000,
|
||||
frameSize:1024,
|
||||
gain:1.0 //增益值,数字越高音频声音越大 1.0~20.0
|
||||
})
|
||||
},
|
||||
onStopRecord(){
|
||||
//停止录音
|
||||
this.$refs.recordFrame.stop();
|
||||
},
|
||||
//停止录音
|
||||
stopIt(base64){
|
||||
//base64音频只能在浏览器播放
|
||||
//也可以base64音频转成文件音频可app播放
|
||||
console.log(base64);
|
||||
},
|
||||
frameRecorded({isLastFrame,frameBuffer}){
|
||||
console.log(isLastFrame,frameBuffer);
|
||||
if(!isLastFrame){
|
||||
this.frameBuffer=frameBuffer;
|
||||
}
|
||||
|
||||
this.isLastFrame=isLastFrame?'true':'false';
|
||||
},
|
||||
onCurrentDecibels(decibels){
|
||||
//当前分贝值 最小可测分贝(-80) 最大可测分贝(-30)
|
||||
console.log("当前分贝:" + decibels)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
```
|
||||
Reference in New Issue
Block a user