uniapp 实时语音转文字 使用 App-Plus(WKWebView)环境下,直接用 navigator.mediaDevices.getUserMedia获取音频buffer,可以直接在安卓和iOS和H5使用
返回发过火
1 <template> 2 <!-- 3 视图层模板: 4 - 使用动态绑定将options和status传递给renderjs模块 5 - change:options 监听options变化,触发record.startRecord方法 6 - change:status 监听status变化,触发record.onStop方法 7 --> 8 <view :options="options" :change:options="record.startRecord" 9 :status="status" :change:status="record.onStop"> 10 </view> 11 </template> 12 13 <script> 14 // Vue组件脚本 - 负责逻辑控制 15 export default { 16 data() { 17 return { 18 options: null, // 录制配置参数 19 status: null, // 录制状态控制 20 // 新增:用于页面显示的时间 21 recordingTime: '00:00', // 格式化显示的时间字符串 22 durationInSeconds: 0 // 录音时长(秒) 23 } 24 }, 25 methods: { 26 // 开始录音方法 27 start(option) { 28 this.recordingTime = '00:00'; // 重置时间显示 29 this.options = option; // 设置录制参数 30 this.status = 'start'; // 触发renderjs开始录制 31 }, 32 33 // 停止录音方法 34 stop() { 35 this.status = 'stop'; // 触发renderjs停止录制 36 this.options = null; // 清空配置 37 }, 38 39 // 更新计时器 40 async updateTimer(seconds) { 41 this.durationInSeconds = seconds; // 记录总秒数 42 this.recordingTime = await this.formatTime(seconds); // 格式化时间 43 this.$emit("onUpdateTime", this.recordingTime) // 向父组件发送时间更新事件 44 }, 45 46 // 时间格式化工具:将秒数转换为 MM:SS 格式 47 formatTime(seconds) { 48 const m = Math.floor(seconds / 60); // 计算分钟 49 const s = Math.floor(seconds % 60); // 计算秒数 50 return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; // 格式化为两位数字 51 }, 52 53 // 处理音频帧数据 54 frameRecorded({ isLastFrame, frameBuffer }) { 55 // 向父组件发送音频帧数据事件 56 this.$emit('onFrameRecorded', { 57 isLastFrame, 58 frameBuffer: this.base64ToUint8Array(frameBuffer) // 将base64转换为Uint8Array 59 }) 60 }, 61 62 // Base64转Uint8Array工具函数 63 base64ToUint8Array(base64) { 64 const binaryString = atob(base64.split(',')[1] || base64); // 解码base64 65 const len = binaryString.length; 66 const bytes = new Uint8Array(len); 67 68 for (let i = 0; i < len; i++) { 69 bytes[i] = binaryString.charCodeAt(i); // 将字符转换为字节 70 } 71 72 return bytes; 73 }, 74 75 // 错误处理:显示错误信息并停止录音 76 toShowToast() { 77 // uni.showToast({ 78 // title: '发生错误,请检查是否有麦克风权限', 79 // icon: 'none' 80 // }); 81 this.stop(); // 停止录音 82 }, 83 84 // 录音完成回调 85 recordedChunks(base64) { 86 this.$emit('onStop', base64) // 向父组件发送完整的录音数据 87 }, 88 } 89 } 90 </script> 91 92 <script module="record" lang="renderjs"> 93 // RenderJS模块 - 运行在WebView渲染层,处理音频录制核心逻辑 94 // 注意:RenderJS可以操作DOM和浏览器API,性能更好 95 96 // 全局变量保存音频相关引用,便于资源释放 97 let mediaStream; // 媒体流对象 98 let audioContext; // 音频上下文 99 let processor; // 音频处理器 100 101 // 全局变量存储录音数据 102 let recordedChunks = []; // 存储音频数据块 103 let currentTotalByteLength = 0; // 当前总字节长度,用于计算录音时长 104 105 export default { 106 data() { 107 return {} 108 }, 109 methods: { 110 // 开始录音主函数 111 async startRecord(options) { 112 console.log(options, "==========") 113 if (options == null) return; // 参数验证 114 if (audioContext) return; // 防止重复启动 115 116 try { 117 // 重置录音数据 118 recordedChunks = []; 119 currentTotalByteLength = 0; 120 121 // 创建音频上下文 122 audioContext = new AudioContext(); 123 const devSampleRate = audioContext.sampleRate; // 获取设备采样率 124 console.log(audioContext, "-------------------") 125 126 // 加载并初始化AudioWorklet处理器 127 await audioContext.audioWorklet.addModule('static/js/processor.worklet.js'); 128 129 // 获取麦克风权限并创建媒体流 130 mediaStream = await navigator.mediaDevices.getUserMedia({ 131 audio: { 132 sampleRate: options.sampleRate, // 设置采样率 133 } 134 }); 135 136 // 获取并打印音频轨道配置 137 const track = mediaStream.getAudioTracks()[0]; 138 const settings = track.getSettings(); 139 console.log(settings.sampleRate, settings.channelCount) 140 141 // 创建音频源节点 142 const source = audioContext.createMediaStreamSource(mediaStream); 143 144 // 创建音频工作节点 145 processor = new AudioWorkletNode(audioContext, 'processor-worklet'); 146 147 // 初始化处理器参数 148 processor.port.postMessage({ 149 type: 'init', 150 data: { 151 frameSize: options.frameSize, // 帧大小(样本数) 152 fromSampleRate: devSampleRate, // 输入采样率 153 toSampleRate: options.sampleRate, // 输出采样率 154 arrayBufferType: 'short16' // 音频数据类型 155 } 156 }); 157 158 // 设置音频数据处理回调 159 processor.port.onmessage = (t) => { 160 var r = t.data, 161 o = r.frameBuffer, 162 n = r.isLastFrame; 163 164 if (null == o ? void 0 : o.byteLength) { 165 // 累计音频数据字节数 166 currentTotalByteLength += o.byteLength; 167 // 计算当前录音时长(字节数 ÷ (采样率 × 2字节/样本)) 168 const currentSeconds = currentTotalByteLength / (options.sampleRate * 2) 169 // 更新Vue组件中的计时器 170 this.$ownerInstance.callMethod('updateTimer', currentSeconds) 171 172 // 按帧大小分割音频数据 173 for (var a = 0; a < o.byteLength;) { 174 const frameData = { 175 isLastFrame: n && a + s >= o.byteLength, 176 frameBuffer: t.data.frameBuffer.slice(a, a + s) // 切割单帧数据 177 }; 178 // 处理单帧数据 179 this.onFrameRecorded(frameData); 180 181 // 存储录音数据(用于后续合并) 182 recordedChunks.push(frameData.frameBuffer); 183 184 a += s; // 移动到下一帧 185 } 186 } else { 187 // 处理无音频数据的情况 188 this.onFrameRecorded(t.data); 189 } 190 }; 191 192 // 连接音频节点:source → processor → destination 193 source.connect(processor); 194 processor.connect(audioContext.destination); 195 196 } catch (err) { 197 // 错误处理 198 this.$ownerInstance.callMethod('toShowToast'); 199 console.log(err, "555555555141414"); 200 } 201 }, 202 203 // 处理单帧音频数据 204 onFrameRecorded({ isLastFrame, frameBuffer }) { 205 // 将音频数据发送到Vue组件 206 this.$ownerInstance.callMethod('frameRecorded', { 207 isLastFrame, 208 frameBuffer: this.toBase64(frameBuffer) // 转换为base64传输 209 }); 210 }, 211 212 // ArrayBuffer转Base64工具函数 213 toBase64(buffer) { 214 let binary = ""; 215 const bytes = new Uint8Array(buffer); 216 const len = bytes.byteLength; 217 for (let i = 0; i < len; i++) { 218 binary += String.fromCharCode(bytes[i]); // 将字节转为字符 219 } 220 return window.btoa(binary); // 编码为base64 221 }, 222 223 // 合并所有音频数据块并生成WAV文件 224 async onRecordedChunks(chunks) { 225 // 1. 合并所有音频buffer 226 var mergedBuffer = this.mergeAudioBuffers(chunks); 227 228 // 2. 将PCM数据封装为WAV格式(添加WAV文件头) 229 const wavBlob = this.createWavBlob(mergedBuffer, 1, 16000); // 单声道,16000Hz采样率 230 231 // 3. 将WAV Blob转换为Base64 232 const base64 = await this.blobToBase64(wavBlob); 233 234 // 4. 发送到Vue组件 235 this.$ownerInstance.callMethod('recordedChunks', base64) 236 }, 237 238 // 停止录音处理 239 onStop(value) { 240 if (value !== 'stop') return; // 状态验证 241 242 // 发送最后一帧标记 243 this.onFrameRecorded({ 244 isLastFrame: true, 245 frameBuffer: '' 246 }); 247 248 // 处理已录制的音频数据 249 this.onRecordedChunks(recordedChunks); 250 251 // 释放资源 252 if (mediaStream) { 253 // 停止所有媒体轨道 254 mediaStream.getTracks().forEach(track => track.stop()); 255 mediaStream = null; 256 } 257 258 if (processor) { 259 // 断开音频节点连接 260 processor.disconnect(); 261 processor = null; 262 } 263 264 if (audioContext) { 265 // 关闭音频上下文 266 audioContext.close().then(() => { 267 audioContext = null; 268 }); 269 } 270 }, 271 272 // 合并多个ArrayBuffer 273 mergeAudioBuffers(buffers) { 274 // 计算总长度 275 let totalLength = buffers.reduce((acc, buf) => acc + buf.byteLength, 0); 276 277 // 创建合并后的buffer 278 const result = new Uint8Array(totalLength); 279 let offset = 0; 280 281 // 逐个复制buffer 282 buffers.forEach(buffer => { 283 result.set(new Uint8Array(buffer), offset); 284 offset += buffer.byteLength; 285 }); 286 287 // 验证长度 288 if (offset !== totalLength) console.error("合并后的长度不符!"); 289 290 return result.buffer; 291 }, 292 293 // 创建WAV格式的Blob(添加WAV文件头) 294 createWavBlob(pcmData, numChannels, sampleRate) { 295 const bytesPerSample = 2; // 16-bit PCM,每样本2字节 296 const blockAlign = numChannels * bytesPerSample; // 每个采样帧的字节数 297 const byteRate = sampleRate * blockAlign; // 每秒字节数 298 const bufferLength = pcmData.byteLength; // PCM数据长度 299 const totalLength = 44 + bufferLength; // WAV头(44字节) + 音频数据 300 301 // 创建DataView写入WAV头 302 const buffer = new ArrayBuffer(totalLength); 303 const view = new DataView(buffer); 304 305 // 写入RIFF头 306 this.writeString(view, 0, 'RIFF'); // 文件标识 307 view.setUint32(4, totalLength - 8, true); // 文件大小(不包括RIFF标识和大小字段) 308 this.writeString(view, 8, 'WAVE'); // 格式类型 309 310 // 写入fmt子块 311 this.writeString(view, 12, 'fmt '); // fmt块标识 312 view.setUint32(16, 16, true); // fmt块大小 313 view.setUint16(20, 1, true); // 音频格式(PCM = 1) 314 view.setUint16(22, numChannels, true); // 声道数 315 view.setUint32(24, sampleRate, true); // 采样率 316 view.setUint32(28, byteRate, true); // 字节率 317 view.setUint16(32, blockAlign, true); // 块对齐 318 view.setUint16(34, 16, true); // 位深(16位) 319 320 // 写入data子块 321 this.writeString(view, 36, 'data'); // data块标识 322 view.setUint32(40, bufferLength, true); // 音频数据大小 323 324 // 写入PCM音频数据(16位小端序) 325 const pcm16 = new Int16Array(pcmData); 326 for (let i = 0; i < pcm16.length; i++) { 327 view.setInt16(44 + i * 2, pcm16[i], true); // 44字节偏移后开始写音频数据 328 } 329 330 // 返回WAV格式的Blob 331 return new Blob([buffer], { 332 type: 'audio/wav' 333 }); 334 }, 335 336 // Blob转Base64工具函数 337 blobToBase64(blob) { 338 return new Promise((resolve, reject) => { 339 const reader = new FileReader(); 340 reader.onloadend = () => resolve(reader.result); // 读取完成 341 reader.onerror = reject; // 读取错误 342 reader.readAsDataURL(blob); // 读取为DataURL格式 343 }); 344 }, 345 346 // 辅助函数:将字符串写入DataView 347 writeString(view, offset, string) { 348 for (let i = 0; i < string.length; i++) { 349 view.setUint8(offset + i, string.charCodeAt(i)); // 写入字符的ASCII码 350 } 351 } 352 } 353 } 354 </script> 355 356 <style></style>
-
架构设计:
-
使用uni-app的RenderJS技术处理音频录制,将性能敏感的操作放在渲染层
-
Vue组件层负责状态管理和事件传递
-
RenderJS层负责音频处理、编码和文件生成
-
-
音频处理流程:
-
获取麦克风权限 → 创建音频流 → 采样率转换 → 分帧处理 → 合并为WAV文件
-
-
数据流转:
-
PCM音频数据 → 分帧 → Base64编码 → Vue组件 → Uint8Array解码
-
-
资源管理:
-
全局变量保存音频相关引用,确保正确释放资源
-
完整的错误处理和资源清理机制
-
-
功能特点:
-
实时录音时长计算和显示
-
支持实时音频帧传输
-
最终生成标准WAV格式文件
-
支持自定义采样率和帧大小
-

浙公网安备 33010602011771号