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>
  1. 架构设计:

    • 使用uni-app的RenderJS技术处理音频录制,将性能敏感的操作放在渲染层

    • Vue组件层负责状态管理和事件传递

    • RenderJS层负责音频处理、编码和文件生成

  2. 音频处理流程:

    • 获取麦克风权限 → 创建音频流 → 采样率转换 → 分帧处理 → 合并为WAV文件

  3. 数据流转:

    • PCM音频数据 → 分帧 → Base64编码 → Vue组件 → Uint8Array解码

  4. 资源管理:

    • 全局变量保存音频相关引用,确保正确释放资源

    • 完整的错误处理和资源清理机制

  5. 功能特点:

    • 实时录音时长计算和显示

    • 支持实时音频帧传输

    • 最终生成标准WAV格式文件

    • 支持自定义采样率和帧大小

posted @ 2025-12-30 11:31  圣迭戈  阅读(18)  评论(0)    收藏  举报