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

6
src/constant/speech.js Normal file
View File

@@ -0,0 +1,6 @@
// App 端 yao-asdRealSpeech 使用的阿里云 DashScope 实时语音识别配置。
// 将 apikey 填成实际的 DashScope API Key 后App 端语音识别即可发起连接。
export const appSpeechRecognitionOptions = {
apikey: "SnoHqdtJ832riRg4",
language_hints: ["zh"],
};

View File

@@ -44,10 +44,16 @@
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
"<uses-feature android:name=\"android.hardware.camera\"/>",
"<uses-permission android:name=\"android.permission.INTERNET\"/>",
"<uses-permission android:name=\"android.permission.RECORD_AUDIO\"/>",
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
]
},
"ios" : {},
"ios" : {
"privacyDescription" : {
"NSMicrophoneUsageDescription" : "用于语音输入时录制并识别您的语音内容"
}
},
"sdkConfigs" : {
"oauth" : {}
}

View File

@@ -74,6 +74,15 @@
<!-- 录音按钮 -->
<RecordingWaveBtn v-if="visibleWaveBtn" ref="recordingWaveBtnRef" />
<!-- #ifdef APP-PLUS -->
<yao-asdRealSpeech
ref="appSpeechRef"
:options="appSpeechOptions"
@result="handleAppSpeechResult"
@change="handleAppSpeechChange"
/>
<!-- #endif -->
<view class="color-99A0AE font-size-9 text-center text-gray-400">
内容由AI大模型生成请仔细鉴别
</view>
@@ -84,15 +93,25 @@
import { ref, computed, watch, nextTick, onMounted, defineExpose, onUnmounted } from "vue";
import RecordingWaveBtn from "@/components/Speech/RecordingWaveBtn.vue";
import { getCurrentConfig } from "@/constant/base";
import { appSpeechRecognitionOptions } from "@/constant/speech";
let manager = null;
let speechProvider = "";
const isSpeechRecognitionSupported = ref(false);
const appSpeechOptions = appSpeechRecognitionOptions;
// WechatSI 是微信小程序插件App 原生基座没有 requirePlugin。
// #ifdef MP-WEIXIN
const plugin = requirePlugin("WechatSI");
manager = plugin.getRecordRecognitionManager();
isSpeechRecognitionSupported.value = !!manager;
speechProvider = manager ? "wechat" : "";
// #endif
// App 端使用 yao-asdRealSpeechapikey 请在 src/constant/speech.js 中配置。
// #ifdef APP-PLUS
isSpeechRecognitionSupported.value = true;
speechProvider = "app";
// #endif
const props = defineProps({
@@ -112,6 +131,7 @@ const emit = defineEmits([
const textareaRef = ref(null);
const recordingWaveBtnRef = ref(null);
const appSpeechRef = ref(null);
const placeholder = computed(() => {
const config = getCurrentConfig();
return `快告诉${config.name}您在想什么~`;
@@ -122,7 +142,10 @@ const keyboardHeight = ref(0);
const isVoiceMode = ref(false);
const visibleWaveBtn = ref(false);
const isRecording = ref(false);
const appRecognizedText = ref("");
let watchDogTimer = null;
let appStopFallbackTimer = null;
let hasSentAppRecognition = false;
const resetUI = () => {
isRecording.value = false;
@@ -148,6 +171,13 @@ const startWatchDog = (timeout = 10000) => {
}, timeout);
};
const clearAppStopFallback = () => {
if (appStopFallbackTimer) {
clearTimeout(appStopFallbackTimer);
appStopFallbackTimer = null;
}
};
// 保持和父组件同步
watch(
() => props.modelValue,
@@ -172,9 +202,38 @@ const toggleVoiceMode = () => {
// 处理语音按钮长按开始
const handleVoiceTouchStart = () => {
if (!manager) return;
if (!isSpeechRecognitionSupported.value) return;
try {
manager.start({ lang: "zh_CN" });
if (speechProvider === "wechat") {
if (!manager) return;
manager.start({ lang: "zh_CN" });
} else if (speechProvider === "app") {
if (!appSpeechOptions.apikey) {
uni.showToast({
title: "请先配置语音识别API Key",
icon: "none",
});
return;
}
appRecognizedText.value = "";
hasSentAppRecognition = false;
const appSpeech = appSpeechRef.value;
if (!appSpeech || typeof appSpeech.start !== "function") {
uni.showToast({
title: "语音组件未初始化",
icon: "none",
});
return;
}
appSpeech.start();
} else {
return;
}
isRecording.value = true;
visibleWaveBtn.value = true;
@@ -194,7 +253,7 @@ const handleVoiceTouchStart = () => {
// 处理语音按钮长按结束
const handleVoiceTouchEnd = () => {
if (!manager) {
if (!isSpeechRecognitionSupported.value) {
resetUI();
return;
}
@@ -213,7 +272,16 @@ const handleVoiceTouchEnd = () => {
}
try {
manager.stop();
if (speechProvider === "wechat") {
manager?.stop?.();
} else if (speechProvider === "app") {
appSpeechRef.value?.stop?.();
clearAppStopFallback();
appStopFallbackTimer = setTimeout(() => {
appStopFallbackTimer = null;
sendAppRecognizedText();
}, 600);
}
} catch (err) {
console.error("record stop error:", err);
} finally {
@@ -255,6 +323,60 @@ const initRecord = () => {
};
};
const getAppSpeechText = (res) => {
return (
res?.sentence?.text ||
res?.text ||
res?.result ||
res?.payload?.output?.sentence?.text ||
""
);
};
const sendAppRecognizedText = () => {
if (hasSentAppRecognition) return;
const text = appRecognizedText.value.trim();
if (!text) {
console.log("没有说话");
return;
}
hasSentAppRecognition = true;
clearAppStopFallback();
inputMessage.value = text;
emit("send", text);
appRecognizedText.value = "";
};
const handleAppSpeechResult = (res) => {
const text = getAppSpeechText(res);
if (!text) return;
appRecognizedText.value = text;
inputMessage.value = text;
};
const handleAppSpeechChange = (msg) => {
if (!msg || !msg.status) return;
if (msg.status === "START") {
isRecording.value = true;
return;
}
if (msg.status === "STOP") {
resetUI();
sendAppRecognizedText();
return;
}
if (msg.status === "ERROR") {
console.error("app speech recognition error", msg.msg);
resetUI();
}
};
// 监听键盘高度变化
onMounted(() => {
// 监听键盘弹起
@@ -281,6 +403,14 @@ onUnmounted(() => {
manager.onStop = null;
manager.onError = null;
}
if (appSpeechRef.value) {
try {
appSpeechRef.value.stop && appSpeechRef.value.stop();
} catch (e) {
// ignore
}
}
clearAppStopFallback();
resetUI();
});

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>
```