流程图:
话不多说,上代码:
javascript
<template>
<view class="content">
<view class="speech-chat" @longpress="startSpeech" @touchend="endSpeech">
<view class="animate-block" v-if="voiceState">
<view class="dot-ripple"></view>
<view class="dot-ripple"></view>
</view>
<image class="speech-icon" src="/static/aichat/icon-speech.png" mode="aspectFit"></image>
</view>
</view>
</template>
<script lang="ts" setup>
const recorderManager = ref(uni.getRecorderManager()) // 获取全局唯一的录音管理器
// 这俩有啥用?
const innerAudioContext = uni.createInnerAudioContext() // 创建并返回内部audio上下文对象
innerAudioContext.autoplay = true // 开启自动播放设置
const voicePath = ref<string>('') // 语音文件路径
const result = ref<string>('') // 语音识别结果
const voiceState = ref<boolean>(false) // 语音输入状态
const authState = ref<boolean>(false) // 录音授权状态
const bdData = reactive({
pid: '你的pid',
cuid: '你的cuid',
token: '你的token',
voiceUrl: 'https://vop.baidu.com/server_api',
})
const emit = defineEmits(['speech-result'])
// 初始化录音配置
const initRecorder = () => {
recorderManager.value.onStart(() => {
// console.log('录音开始')
})
recorderManager.value.onStop((res) => {
readFile(res.tempFilePath) // 文件读取事件
voicePath.value = res.tempFilePath
})
}
/**
* @param {*} voiceFilePath 文件流路径
* @desc 文件流或文件路径获取
*/
const readFile = (voiceFilePath) => {
// 通过全局唯一的<文件管理器>读取文件信息
uni.getFileSystemManager().readFile({
filePath: voiceFilePath,
success: (res) => {
// console.log('文件编码成功', res)
getRecognizeResult(res.data) // 调用百度语言识别API
},
fail: (err) => {
// console.log('文件编码失败', err)
return err
},
})
}
/**
* @param {*} speechResult 文件流路径
* @desc 百度语音识别获取
*/
const getRecognizeResult = (speechResult) => {
const questUrl = `${bdData.voiceUrl}?cuid=${bdData.cuid}&token=${bdData.token}&dev_pid=${bdData.pid}`
uni.request({
url: questUrl,
method: 'POST',
header: {
'Content-Type': 'audio/pcm;rate=16000', // 注意这个header用于设置音频文件格式和码率
},
data: speechResult,
success: (res) => {
console.log('请求成功', res)
result.value = res.data.result[0]
emit('speech-result', res.data.result[0])
},
fail: (err) => {
console.log('请求失败', err)
uni.showToast({
title: '语言识别异常,稍后重试',
icon: 'error',
duration: 2000,
})
},
complete: () => {
voiceState.value = false
},
})
}
const startRecord = () => {
// 开始录音并设置格式
recorderManager.value.start({
format: 'PCM',
})
}
/**
* @desc 长按触发录音
*/
const startSpeech = (e) => {
// 震动提示用户--不一定生效
uni.vibrateLong({
success: function () {
// console.log('success')
},
})
// 已经授权 则开始录音
if (authState.value) {
voiceState.value = true
startRecord()
return
}
// 未授权--主要用于检查用户是否已授权某些敏感权限(如录音、摄像头、位置等)
uni.getSetting({
success(settingRes) {
// 未授权
if (!settingRes.authSetting['scope.record']) {
// 只保留一种授权方式,避免重复弹框
uni.authorize({
scope: 'scope.record',
success() {
authState.value = true
voiceState.value = true
startRecord()
},
fail() {
// 用户拒绝授权后,引导进入设置页面开启授权
uni.showModal({
title: '提示',
content: '需要录音权限才能使用语音识别功能',
confirmText: '去设置',
success(res) {
if (res.confirm) {
uni.openSetting({
success(res) {
if (res.authSetting['scope.record']) {
authState.value = true
voiceState.value = true
startRecord()
}
},
})
}
},
})
},
})
} else {
authState.value = true
voiceState.value = true
startRecord()
}
},
})
}
/**
* @desc 检查录音授权状态
*/
const checkRecordAuth = () => {
// 获取应用的权限设置
uni.getSetting({
success(res) {
// 如果已经授权录音权限,更新状态
if (res.authSetting['scope.record']) {
authState.value = true
}
},
})
}
const endSpeech = (e) => {
// 只有在录音状态时才停止录音
if (voiceState.value) {
voiceState.value = false
// 结束录音
recorderManager.value.stop()
}
}
onMounted(() => {
// 初始化录音管理器
initRecorder()
// 初始化检查录音权限状态
checkRecordAuth()
})
</script>
<style lang="scss" scoped>
.speech-chat {
position: relative;
bottom: 0;
left: 50%;
box-sizing: border-box;
width: 140rpx;
height: 140rpx;
border-radius: 30rpx;
transform: translateX(-50%);
image {
box-sizing: border-box;
width: 140rpx;
height: 140rpx;
border-radius: 50%;
}
}
.speech-icon {
position: absolute;
z-index: 2;
}
/* 波纹动画容器 */
.dot-ripple {
position: absolute;
top: 40%; /* 定位到父容器垂直中点 */
/* 新增居中代码 */
left: 50%; /* 定位到父容器水平中点 */
z-index: 1;
width: 20rpx;
height: 20rpx;
background-color: #ff4200;
border-radius: 50%;
box-shadow: 0 0 20rpx rgba(0, 0, 0, 0.3) inset;
transform: translate(-50%, -50%); /* 自身尺寸反向位移50% */
animation: ripple 1s ease infinite;
}
/* 动画定义 */
@keyframes ripple {
0% {
width: 0;
height: 0;
opacity: 0.75;
transform: translate(-50%, -50%) scale(0); /* 保持居中缩放 */
}
100% {
width: 280rpx;
height: 280rpx;
opacity: 0;
transform: translate(-50%, -50%) scale(1); /* 保持居中放大 */
}
}
/* 小程序兼容 */
/* #ifdef MP-WEIXIN */
.dot-ripple {
transform: translateZ(0); /* 触发硬件加速 */
}
/* #endif */
.dot-ripple:nth-child(2) {
animation-delay: 0.2s;
}
</style>