feat: 集成语音识别

This commit is contained in:
2026-05-14 17:42:49 +08:00
parent 29338ec706
commit 11c1a3401d
7 changed files with 735 additions and 5 deletions

View File

@@ -0,0 +1,9 @@
## 1.0.32026-04-20
修复录音时间长合成音频文件失败问题
## 1.0.22025-12-06
.
## 1.0.12025-12-05
停止录音后保存音频
## 1.0.02025-08-17
# yao-asdRealtimeSpeech
阿里云Paraformer实时语音识别

View File

@@ -0,0 +1,402 @@
<template>
<view
:recorderStatus="recorderStatus"
:change:recorderStatus="recorder.startRecord">
</view>
</template>
<script>
var ws;
export default{
data(){
return{
recorderStatus:null,
task_id:'',
}
},
methods:{
start(){
this.webSocekt();
},
stop(){
this.recorderStatus="stop";
if(ws){
ws.close();
}
},
toShowToast(){
uni.showToast({
title:'发生错误,请检查是否有麦克风权限',
icon:'none'
});
this.recorderStatus="stop";
},
startRecorder(){
this.$emit('change',{status:"START",msg:"开始录音"});
},
webSocekt(){
ws = uni.connectSocket({
url: 'wss://dashscope.aliyuncs.com/api-ws/v1/inference',
header: {
"Authorization": "bearer "+this.options.apikey, // 将<your_dashscope_api_key>替换成您自己的API Key
"X-DashScope-DataInspection": "enable"
},
method: 'GET',
complete(){
}
});
ws.onOpen(()=>{
var param={
header: {
action: "run-task",
task_id: this.generateRandom32(), // 随机uuid
streaming: "duplex"
},
payload:{
task_group:"audio",
task:"asr",
function:"recognition",
model:"paraformer-realtime-v2",
parameters:{
format:"pcm",
sample_rate:16000,
language_hints:this.options.language_hints
},
input:{}
}
};
ws.send({data:JSON.stringify(param)});
});
ws.onMessage((data)=>{
var msg=JSON.parse(data.data)
if(msg.header.event=="task-started"){
this.task_id=msg.header.task_id;
this.recorderStatus="start";
}
if(msg.header.event=="result-generated"){
this.$emit('result',msg.payload.output);
}
});
ws.onError((err)=>{
this.$emit('change',{status:"ERROR",msg:err});
});
ws.onClose(()=>{
this.$emit('change',{status:"CLOSE",msg:"连接已关闭"});
})
},
generateRandom32() {
let result = '';
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
for (let i = 0; i < 32; i++) {
const randomIndex = Math.floor(Math.random() * chars.length);
result += chars[randomIndex];
}
return result;
},
frameRecorded({isLastFrame,frameBuffer}){
ws.send({data:uni.base64ToArrayBuffer(frameBuffer)});
},
recordedChunks(base64){
var fileName = 'YP'+Date.now();
this.base64ToFile(base64,fileName,(path)=>{
this.$emit('change',{status:"STOP",msg:{
path:path,
base64:base64
}})
});
//this.$emit('change',{status:"STOP",msg:base64});
},
//把base64编码转成文件
base64ToFile (base64Str, fileName, callback) {
// 去除base64前缀
var index=base64Str.indexOf(',')
var base64Str=base64Str.slice(index+1,base64Str.length)
plus.io.requestFileSystem(plus.io.PRIVATE_DOC, function(fs){
fs.root.getFile(fileName, {create: true}, function(entry){
var fullPath = entry.fullPath;
let platform = uni.getSystemInfoSync().platform
if(platform == 'android'){
var FileOutputStream = plus.android.importClass("java.io.FileOutputStream");
var Base64 = plus.android.importClass("android.util.Base64");
try {
var out = new FileOutputStream(fullPath);
// 分块解码 - 每块300KB Base64解码后约225KB
var CHUNK_SIZE = 300 * 1024; // 300KB
var totalLen = base64Str.length;
var processedLen = 0;
while(processedLen < totalLen) {
var end = Math.min(processedLen + CHUNK_SIZE, totalLen);
var chunk = base64Str.substring(processedLen, end);
// 确保chunk长度是4的倍数Base64要求
if(chunk.length % 4 !== 0) {
chunk += '='.repeat(4 - chunk.length % 4);
}
var bytes = Base64.decode(chunk, Base64.DEFAULT);
out.write(bytes);
processedLen = end;
console.log('解码进度:', Math.floor(processedLen/totalLen*100) + '%');
}
out.close();
callback && callback(entry.toLocalURL());
} catch(e) {
console.log('分块写入失败:', e.message);
// 失败时尝试使用文件写入API
this.saveBase64AsFile(fullPath, base64Str, callback);
}
}
})
})
},
saveBase64AsFile(fullPath, base64Str, callback) {
// 将base64转为ArrayBuffer
var binaryString = atob(base64Str);
var bytes = new Uint8Array(binaryString.length);
for (var i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
// 使用 uni.writeFile
uni.writeFile({
filePath: fullPath,
data: bytes.buffer,
success: () => {
console.log('uni.writeFile 成功');
callback && callback(fullPath);
},
fail: (err) => {
console.error('uni.writeFile 失败:', err);
// 最终方案:直接保存为临时文件
this.saveAsTempFile(bytes.buffer, callback);
}
});
}
},
props:['options']
}
</script>
<script module="recorder" lang="renderjs">
// 保存需要关闭的引用
let mediaStream;
let audioContext;
let processor;
// 全局变量存储录音数据
let recordedChunks = [];
export default {
mounted() {
},
methods: {
async startRecord(val){
if(val==null) return;
if(val == "start"){
try{
recordedChunks = [];
// 配置参数
var a, i = 16000,
s = 1280;
audioContext = new AudioContext();
const devSampleRate = audioContext.sampleRate; // 例如 44100、48000 等
// 加载并初始化AudioWorklet
await audioContext.audioWorklet.addModule('static/dist/processor.worklet.js');
//const mediaStream = new MediaStream();
mediaStream = await navigator.mediaDevices.getUserMedia({
audio: {
sampleRate: 16000,
}
});
const source = audioContext.createMediaStreamSource(mediaStream);
processor = new AudioWorkletNode(audioContext, 'processor-worklet');
// 初始化处理器
processor.port.postMessage({
type: 'init',
data: {
frameSize: 1280, // 样本数 (1280字节 / 2字节每样本)
fromSampleRate: devSampleRate, // 输入采样率
toSampleRate: 16000, // 输出采样率 (1/3)
arrayBufferType: 'short16'
}
});
// 接收40ms间隔的音频数据
processor.port.onmessage = (t) => {
var r = t.data,
o = r.frameBuffer,
n = r.isLastFrame;
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);
this.$ownerInstance.callMethod('startRecorder');
}catch(err){
console.error(err);
this.$ownerInstance.callMethod('toShowToast');
}
}else if(val == "stop"){
this.onStop();
}
},
onFrameRecorded({isLastFrame,frameBuffer}){
this.$ownerInstance.callMethod('frameRecorded', {isLastFrame,frameBuffer:this.toBase64(frameBuffer)});
},
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(){
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>

View File

@@ -0,0 +1,97 @@
{
"id": "yao-asdRealSpeech",
"displayName": "阿里云实时语音识别",
"version": "1.0.3",
"description": "阿里云实时语音识别 支持Android,ios",
"keywords": [
"实时语音识别,语音识别"
],
"repository": "",
"engines": {
"HBuilderX": "^3.1.0",
"uni-app": "^4.07",
"uni-app-x": ""
},
"dcloudext": {
"type": "component-vue",
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": "3371387322@qq.com"
},
"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": "x",
"chrome": "x"
},
"app": {
"vue": "√",
"nvue": "x",
"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": "-"
}
}
}
}
}
}

View File

@@ -0,0 +1,80 @@
# yao-asdRealtimeSpeech
# ##配置
需要将模块下uni_modules/yao-asdRealSpeech的dist复制到static目录下面
或者
将uni_modules/yao-asdRealSpeech/dist目录的配置文件放到static/dist目录下面
# 代码示例
```javascript
<template>
<view class="content">
<view class="btn-start" @click="onStart">开始说话</view>
<view class="btn-stop" @click="onStop">停止说话</view>
<view style="width:90%;margin:0 auto;;">
{{text}}
</view>
<yao-asdRealSpeech
ref="asdTimeSpeeh"
:options="asdOptions"
@result="onResult"
@change="onChange"
></yao-asdRealSpeech>
</view>
</template>
<script>
export default {
data() {
return {
text:"",
asdOptions:{
apikey:"", //自己申请的密钥
language_hints:["zh","ja"], //识别语言代码 如果无法提前确定语种,可不设置 (当前设置了中文+日语)
},
}
},
onLoad() {
},
methods: {
onStart(){
//开始识别
console.log('开始识别');
this.$refs.asdTimeSpeeh.start();
this.text="";
},
onStop(){
//停止识别
console.log('停止识别');
this.$refs.asdTimeSpeeh.stop();
},
onResult(res){
//识别结果
console.log(res);
this.text = res.sentence.text;
},
onChange(msg){
console.log(msg)
if(msg.status == "CLOSE"){
//连接已关闭
}else if(msg.status == "START"){
//开始录音
}else if(msg.status == "ERROR"){
//连接发生错误
}else if(msg.status == "STOP"){
console.log("音频文件",msg.msg.path);
console.log("音频编码base64",msg.msg.base64)
}
}
}
}
</script>
```