实时语音转写在uni-app中的探索与挑战:踩坑经历与解决思路

1、需求背景

简短的概述就是要实现一个安卓APP,里面主要的一个核心功能之一就是实现录音,然后实时转写成文字。因为自己掉在坑里所以写了这篇文章希望可以帮助到有需要的小伙伴

2、实现思路

uni-app里有一个百度语音转写,那个实现比较简单。存在一些限制

2.1、查阅讯飞API文档发现有两种实现方案

① 边录音边转写

录音的时候,监听已录制完指定帧大小的文件事件,会回调录制的文件内容。于讯飞接口建立WebSocket连接后,根据实时回调事件向接口发送录音分片数据数组,然后获取转写结果进行拼接。

下面是监听已录制完指定帧大小的文件后触发的回调事件:

js 复制代码
const listener = ({isLastFrame, frameBuffer} : {
  isLastFrame: boolean;
  frameBuffer: ArrayBuffer;
}) => {}

因为官方demo是WEB网页中实现的,而uni-app是打包成安卓APP,实现录音的时候无法做到录制完指定帧大小的文件事件,会回调录制的文件内容,看到了一些付费插件,通过原生的方法实现了,这就duck不必了,遂采用方案②尝试。

② 将录音文件进行转写

拿到音频文件后,转换为ArrayBuffer格式,根据接口要求将数据拆分成分片数据数组,于讯飞接口建立WebSocket连接后,向接口发送数据,然后获取转写结果进行拼接。

下面是拿到文件拆分成符合接口要求的数据,并发送:

js 复制代码
  document.getElementById("input_file").onchange = (e) => {
    if (e.target.files[0]) {
      connectWebSocket(() => {
        const reader = new FileReader();
        reader.readAsArrayBuffer(e.target.files[0]);
        
        reader.onload = (evt) => {
          console.log(evt.target.result);
          const audioString = toString(evt.target.result)
          // console.log(audioString.length, evt.target.result.byteLength)
          let offset = 0;

          while(offset < audioString.length) {
            const subString = audioString.substring(offset, offset + 1280)
            offset += 1280
            // console.log(subString.length, subString)
            const isEnd = offset >= audioString.length
            iatWS.send(
              JSON.stringify({
                data: {
                  status: isEnd ? 2 : 1,
                  format: "audio/L16;rate=16000",
                  encoding: "raw",
                  audio: window.btoa(subString)
                },
              })
            );
          }
        };
        reader.onerror = () => {
          iatWS.close();
        };
      });
    }
  };

在uni-app中打包成安卓实现录音功能的时候,是可以拿到录音文件的路径,即可以拿到文件,然后处理方式就跟上面类似,要注意的地方就是环境不同,demo是浏览器环境,我们是安卓环境, 但是方案是可行的。

3、实现代码

js 复制代码
...
其他页面代码
...
<script setup>
    // 项目中要引入demo的iat-js文件夹,页面引入以下文件。
    import '../iat-js/example/crypto-js.js'
    import '../iat-js/dist/index.umd.js'
    import { pathToBase64 } from '../../utils/toBase64.js'
    
    // 讯飞服务接口认证信息,讯飞开放平台控制台创建的应用后获取
    let APPID = "";
    let API_SECRET = "";
    let API_KEY = "";
    
    // 获取全局唯一的录音管理器
    const recorderManager = uni.getRecorderManager();
    
    ...
    其他逻辑代码
    ...
    
    // 录音触发事件
    const startRecognize = () => {
        // 建立webSocket连接
        connectWebSocket();
    }
    
    // 建立连接
    const connectWebSocket = () => {
        // 获取websocket的url地址
        const websocketUrl = getWebSocketUrl();
        uni.connectSocket({
            url: websocketUrl,
            protocols: null,
            success: (res) => {console.log('这里是接口调用成功的回调', res)},
            fail: (err) => {console.log('uni.connectSocket fail', err)},
        })
        uni.onSocketOpen((res) => {
            uni.hideLoading()
            uni.showToast({icon: 'none',title: '连接成功'})
            
            // 开始录音
            recorderManager.start({sampleRate: 16000,format: 'mp3'});
            
            // 第一次接口需求的入参
            var params = {
                common: { app_id: APPID }
                business: {
                    language: "zh_cn",
                    domain: "iat",
                    accent: "mandarin",
                    vad_eos: 5000,
                    dwa: "wpgs",
                },
                data: {
                    status: 0,
                    format: "audio/L16;rate=16000",
                    encoding: "lame",
                },
            };
            
            // 向接口发送数据
            send(JSON.stringify(params))
        })
        uni.onSocketError((err) => {
            uni.hideLoading()
            uni.showModal({
                content: '连接失败,可能是websocket服务不可用,请稍后再试',
                showCancel: false,
            })
            console.log('onError', err)
        })
        uni.onSocketMessage((res) => {
            console.log('onMessage', res)
            // 接收接口返回的转写数据
            renderResult(res.data)
        })
        uni.onSocketClose((res) => {
            console.log('onClose', res)
        })
    };
    
    
    // demo中有此函数复用即可
    const getWebSocketUrl = () => {
           // 请求地址根据语种不同变化
		var url = "wss://iat-api.xfyun.cn/v2/iat";
		var host = "iat-api.xfyun.cn";
		var apiKey = API_KEY;
		var apiSecret = API_SECRET;
		var date = new Date().toGMTString();
		var algorithm = "hmac-sha256";
		var headers = "host date request-line";
		var signatureOrigin = `host: ${host}\ndate: ${date}\nGET /v2/iat HTTP/1.1`;
		var signatureSha = CryptoJS.HmacSHA256(signatureOrigin, apiSecret);
		var signature = CryptoJS.enc.Base64.stringify(signatureSha);
		var authorizationOrigin =
			`api_key="${apiKey}", algorithm="${algorithm}", headers="${headers}", signature="${signature}"`;
		var authorization = btoa(authorizationOrigin);
		url = `${url}?authorization=${authorization}&date=${date}&host=${host}`;
		return url;
    }
    
        // demo中有此函数复用即可
    	const renderResult = (resultData) => {
		let resultText = "";
		let resultTextTemp = "";
		// 识别结束
		let jsonData = JSON.parse(resultData);
		if (jsonData.data && jsonData.data.result) {
			let data = jsonData.data.result;
			let str = "";
			let ws = data.ws;
			for (let i = 0; i < ws.length; i++) {
				str = str + ws[i].cw[0].w;
			}
			// 开启wpgs会有此字段(前提:在控制台开通动态修正功能)
			// 取值为 "apd"时表示该片结果是追加到前面的最终结果;取值为"rpl" 时表示替换前面的部分结果,替换范围为rg字段
			if (data.pgs) {
				if (data.pgs === "apd") {
					// 将resultTextTemp同步给resultText
					resultText = resultTextTemp;
				}
				// 将结果存储在resultTextTemp中
				resultTextTemp = resultText + str;
			} else {
				resultText = resultText + str;
			}
			console.log('成败再次一举之识别结果---resultText', resultText);
			console.log('成败再次一举之识别结果---resultTextTemp', resultTextTemp);
		}
		if (jsonData.code === 0 && jsonData.data.status === 2) {
			close();
		}
		if (jsonData.code !== 0) {
			close();
			console.error(55, jsonData);
		}
	}
    
    
    
	const toString = (buffer) => {
		var binary = '';
		var bytes = new Uint8Array(buffer);
		var len = bytes.byteLength;
		for (var i = 0; i < len; i++) {
			binary += String.fromCharCode(bytes[i]);
		}
		return binary
	}
    
    // 发送数据
    const send = (data) => {
        uni.sendSocketMessage({
            data: data,
            success: (res) => {console.log(res)},
            fail: (err) => {console.log(err)},
        })
    };
    
    // 关闭连接
    const close = () => {
        uni.closeSocket({
            code: 1000,
            reason: 'close reason from client',
            success: (res) => {console.log('uni.closeSocket success', res)},
            fail: (err) => {console.log('uni.closeSocket fail', err)},
        })
    };
    
    onLoad(() => {
        recorderManager.onStop((res) => {
            // 录音文件地址
            console.log('recorder res' + (res.tempFilePath));
            pathToBase64(res.tempFilePath).then(base64 => {
                console.log('base64--', base64);
// 这里一定要注意:当需要将base64字符串转换为Buffer时,通常会去掉前面部分的数据URL标识(如"data:image/png;base64,"),因为这部分内容不是实际的base64编码数据。
                let buff = base64.split(",")[1]
                const arrayBuffer = uni.base64ToArrayBuffer(buff)
                const audioString = toString(arrayBuffer)
                console.log("文件读取成功", audioString.length);
                let offset = 0;
                while (offset < audioString.length) {
                    const subString = audioString.substring(offset, offset + 1280)
                    offset += 1280
                    const isEnd = offset >= audioString.length
                    send(JSON.stringify({
                        data: {
                            status: isEnd ? 2 : 1,
                            format: "audio/L16;rate=16000",
                            encoding: "lame",
                            audio: btoa(subString)
                        },
                    }));
                }
            }).catch(error => {
                console.error(error)
            })
        });
    })
</script>

toBase64.js

js 复制代码
export function pathToBase64(path) {
    return new Promise(function(resolve, reject) {
        // app
        if (typeof plus === 'object') {
            plus.io.resolveLocalFileSystemURL(path, function(entry) {
                entry.file(function(file) {
                    var fileReader = new plus.io.FileReader()
                    fileReader.onload = function(evt) {
                        resolve(evt.target.result)
                    }
                    fileReader.onerror = function(error) {
                        reject(error)
                    }
                    fileReader.readAsDataURL(file)
                }, function(error) {
                    reject(error)
                })
            }, function(error) {
                reject(error)
            })
            
            return
        }
        
        // 微信小程序
        if (typeof wx === 'object' && wx.canIUse('getFileSystemManager')) {
            wx.getFileSystemManager().readFile({
                filePath: path,
                encoding: 'base64',
                success: function(res) {
                    resolve('data:image/png;base64,' + res.data)
                },
                fail: function(error) {
                    reject(error)
                }
            })
            
            return
        }
        
        reject(new Error('not support'))
    })
}

4、总结

一个坑是自己不知道base64转码的问题,导致实时语音转写接口返回一直为空,却未排查到原因。

当需要将base64字符串转换为Buffer时,通常会去掉前面部分的数据URL标识(如"data:image/png;base64,"),因为这部分内容不是实际的base64编码数据。

具体来说,在处理base64编码的数据时,通常会遇到两种情况:

  • 数据URL格式:在浏览器环境中,通过某些API(如FileReader的readAsDataURL方法)获取到的base64编码往往附带有数据URL的前缀,形如"data:[][;base64],"。这个前缀表示数据的MIME类型,以及指示后续内容为base64编码的信息。由于这个前缀并非实际的二进制数据,因此在需要将此类型的字符串转换为Buffer以进行进一步处理时,必须先去除这个前缀。
  • 纯Base64字符串:如果是一个没有数据URL前缀的纯base64字符串,则可以直接将其转换为Buffer,无需去掉任何前部分数据。

以上是所有内容,欢迎大家交流学习~

相关推荐
工程师老罗11 小时前
如何在Android工程中配置NDK版本
android
崔庆才丨静觅11 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby606112 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了12 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅12 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅13 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅13 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment13 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅13 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊13 小时前
jwt介绍
前端