Files
qiming/qiming-mobile/components/voice-recorder-button/voice-recorder-button.uvue

460 lines
12 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<!-- :hover-class="currentState === 'recording' ? 'hover-class' : ''" -->
<button class="voice-recorder-container"
hover-class="hover-class"
hover-start-time="50"
@touchstart="handleTouchStart"
@touchend="handleTouchEnd"
@touchcancel="cancelRecording"
@touchmove="handleTouchMove">
<!-- 点击时隐藏按钮文字 -->
<view v-show="!isTapped">
<!-- 状态提示文字 -->
<text class="hover-text" v-if="currentState === 'default'">{{
t("Mobile.Voice.holdToTalk")
}}</text>
<view class="icon-loading-text" v-else-if="currentState === 'uploading'">
<image
class="icon-loading-image"
src="@/static/assets/icon_loading.svg"
alt=""
:show-menu-by-longpress="false"
@longtap.stop.prevent
@contextmenu.stop.prevent="true"
mode="widthFix" />
<text class="loading-text">{{ t("Mobile.Voice.recognizing") }}</text>
</view>
</view>
</button>
</template>
<script lang="uts" setup>
// #ifdef H5
import jzRecorder from '@/uni_modules/jz-h5-recorder-manager'
// #endif
import {
createAudioUploader,
type AudioFile as UploaderAudioFile,
type TranscriptResult as UploaderTranscriptResult,
type UploadProgress
} from '@/servers/audioUploader'
import { MicrophonePermissionHelper, type PermissionState } from '@/utils/permissionHelper'
import { useI18n } from "@/utils/i18n";
const { t } = useI18n();
// 组件Props类型定义
interface VoiceRecorderProps {
// 录音配置
duration?: number // 最大录音时长 (ms)
format?: string // 音频格式
sampleRate?: number // 采样率
// 会话是否正在进行中(有消息正在处理)
isConversationActive?: boolean
}
// 录音状态类型
type RecordState = 'default' | 'recording' | 'uploading' | 'success' | 'error'
// 音频文件接口
interface AudioFile {
tempFilePath: string
duration: number
fileSize: number
format?: string
}
// 转换结果接口
interface TranscriptResult {
success: boolean
text: string
confidence: number
duration: number
error?: string
}
const props = withDefaults(defineProps<VoiceRecorderProps>(), {
duration: 600000, // 录音时长10分钟
format: 'mp3', // 录音格式
sampleRate: 44100, // 采样率
isConversationActive: false // 会话是否正在进行中
})
const emit = defineEmits<{
// 事件回调
onRecordStart?: () => void,
onRecordStop?: (result: string) => void,
onError?: (error: any) => void,
onUploadProgress?: (progress: number) => void
// onCancelChange?: (isCancelled: boolean) => void
onRecordCancel?: () => void
onRecordEnd?: () => void
}>()
// 响应式状态
const currentState = ref<RecordState>('default')
const isCancelled = ref<boolean>(false)
// 是否点击
const isTapped = ref<boolean>(false)
// 录音管理器
let recorderManager: any = null
// 音频上传服务
let audioUploadService: any = null // 使用audioUploader
// 重置状态
const resetState = () => {
currentState.value = 'default'
isCancelled.value = false
isTapped.value = false
}
// 录音错误
const handleRecordError = (err: any) => {
resetState()
// 重置触摸结束取消录音标志
// #ifdef H5
if (recorderManager && recorderManager.touchEndCancelFlag !== undefined) {
recorderManager.touchEndCancelFlag = false;
}
// #endif
emit('onError', err)
}
// 开始录音
const handleTouchStart = async (e) => {
console.log('开始录音handleTouchStart')
e.preventDefault() // Prevent default to avoid context menu
// 重置触摸结束取消录音标志
// #ifdef H5
if (recorderManager && recorderManager.touchEndCancelFlag !== undefined) {
recorderManager.touchEndCancelFlag = false;
}
// #endif
if (props.isConversationActive) {
emit("onError", new Error(t("Mobile.Voice.conversationActiveError")));
return
}
if (currentState.value !== 'default') {
// emit('onError', new Error('请先停止当前录音' + currentState.value))
return
}
isTapped.value = true;
uni.showLoading({
title: t("Mobile.Voice.connecting"),
})
// 开始录音
if (recorderManager) {
try {
recorderManager.start({
duration: Math.min(props.duration, 600000), // 小程序最大录音时长限制为10分钟
sampleRate: props.sampleRate,
numberOfChannels: 1,
encodeBitRate: 192000,
// #ifdef H5
format: props.format || 'mp3' // 小程序只支持mp3和aac
// #endif
// #ifdef MP-WEIXIN
format: 'mp3'
// #endif
})
} catch (error: any) {
uni.hideLoading()
resetState()
emit('onError', error)
}
}
}
// 监听录音开始
const handleRecordStart = () => {
console.log('录音开始handleRecordStart')
// 设置状态,开始录音,并触发事件
currentState.value = 'recording'
emit('onRecordStart')
uni.hideLoading()
}
// 录音中结束
const handleTouchEnd = () => {
console.log('录音中结束handleTouchEnd')
// #ifdef H5
if (recorderManager && recorderManager.touchEndCancelFlag !== undefined) {
recorderManager.touchEndCancelFlag = true;
}
// #endif
isTapped.value = false;
// 如果正在上传,则不停止录音
if (currentState.value === 'uploading') {
return
}
if (isCancelled.value) {
cancelRecording()
} else {
stopRecording()
}
}
// 取消录音
const cancelRecording = () => {
if (recorderManager) {
recorderManager.stop()
}
resetState()
// 重置触摸结束取消录音标志
// #ifdef H5
if (recorderManager && recorderManager.touchEndCancelFlag !== undefined) {
recorderManager.touchEndCancelFlag = false;
}
// #endif
emit('onRecordCancel')
}
// 录音停止
const handleRecordStop = async (res: any) => {
console.log('录音停止handleRecordStop')
// 录音总时长H5端单位秒小程序端单位毫秒, 后续判断时长是否超过10分钟是以秒为单位所以小程序端需要将毫秒转为秒
let duration = res.duration || 0
// #ifdef MP-WEIXIN || MP-ALIPAY || MP-BAIDU || MP-TOUTIAO || MP-QQ
duration = duration / 1000
// #endif
// 小程序端的录音结果格式可能与H5端不同需要统一处理
const audioFile: AudioFile = {
tempFilePath: res.tempFilePath, // 录音文件的临时路径
duration,
fileSize: res.fileSize || 0, // 录音文件大小,单位字节
format: res.format || 'mp3'
}
// 重置触摸结束取消录音标志
// #ifdef H5
if (recorderManager && recorderManager.touchEndCancelFlag !== undefined) {
recorderManager.touchEndCancelFlag = false;
}
// #endif
// 立即显示上传中状态,给用户明确反馈
currentState.value = 'uploading'
try {
// 使用音频上传服务转换音频
const result = await uploadAndConvertWithService(audioFile)
if (result.success) {
currentState.value = 'success'
emit('onRecordStop', result.text)
// 重置状态
resetState()
} else {
throw new Error(result?.text || t("Mobile.Voice.noSpeechDetected"))
}
} catch (error: any) {
handleRecordError(error)
}
}
// 停止录音
const stopRecording = () => {
if (recorderManager) {
try {
recorderManager.stop()
emit('onRecordEnd')
} catch (error: any) {
// 重置触摸结束取消录音标志
// #ifdef H5
if (recorderManager && recorderManager.touchEndCancelFlag !== undefined) {
recorderManager.touchEndCancelFlag = false;
}
// #endif
currentState.value = 'error'
emit('onError', error)
}
}
// 重置状态
resetState()
}
// 使用audioUploader转换音频
const uploadAndConvertWithService = async (audioFile: AudioFile): Promise<TranscriptResult> => {
if (!audioUploadService) {
throw new Error(t("Mobile.Voice.serviceNotReady"))
}
try {
// 转换音频文件格式
const uploaderAudioFile: UploaderAudioFile = {
tempFilePath: audioFile.tempFilePath,
duration: audioFile.duration,
fileSize: audioFile.fileSize,
format: audioFile.format
}
// 调用audioUploader的上传方法
const result = await audioUploadService.uploadAudio(uploaderAudioFile)
// 转换结果格式
return {
success: result.success,
text: result.text,
}
} catch (error: any) {
return {
success: false,
text: error.message,
}
}
}
// 初始化录音事件监听
const initRecorderEvents = async() => {
if (!recorderManager) return
// 录音开始
recorderManager.onStart(handleRecordStart)
// 录音停止
recorderManager.onStop(handleRecordStop)
// 录音错误
recorderManager.onError(handleRecordError)
}
const initRecorderManager = () => {
// #ifdef H5
// 获取录音管理器
recorderManager = jzRecorder.getRecorderManager()
// #endif
// #ifdef MP-WEIXIN || MP-ALIPAY || MP-BAIDU || MP-TOUTIAO || MP-QQ
// 获取录音管理器
recorderManager = uni.getRecorderManager()
// #endif
// 监听录音事件
initRecorderEvents()
// 初始化音频上传服务
audioUploadService = createAudioUploader()
}
// 初始化录音管理器
onShow(() => {
// #ifdef MP-WEIXIN
initRecorderManager()
// #endif
})
// 初始化录音管理器
onMounted(() => {
initRecorderManager()
})
// 销毁录音管理器等资源
const destroyRecorderManager = () => {
resetState()
if (recorderManager) {
// 清理所有事件监听器
/**
* App/H5端, 支持offStart / offStop / offError 方法解绑回调函数,小程序不支持解绑回调函数,所以需要手动清理事件监听器
*/
// #ifdef H5
if (recorderManager.offStart) {
recorderManager.offStart(handleRecordStart)
}
if (recorderManager.offStop) {
recorderManager.offStop(handleRecordStop)
}
if (recorderManager.offError) {
recorderManager.offError(handleRecordError)
}
// #endif
recorderManager = null
}
audioUploadService = null
}
// 组件卸载时清理资源
onHide(() => {
// #ifdef MP-WEIXIN
destroyRecorderManager()
// #endif
})
// 组件卸载时清理资源
onUnmounted(() => {
destroyRecorderManager()
})
// Add touchmove handler to the button
const handleTouchMove = (e) => {
e.preventDefault();
}
</script>
<style lang="scss" scoped>
.voice-recorder-container {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
height: 90rpx;
// height: 112rpx;
border-radius: 24rpx;
transition: all 0.15s ease;
-webkit-touch-callout: none;
color: #000;
font-size: 32rpx;
font-weight: 400;
background-color: transparent;
padding: 0; // 删除uni-button默认padding
user-select: none;
-webkit-user-select: none;
touch-action: none; /* 防止系统手势干预 */
border: none;
&::after {
border: none !important;
}
.hover-text {
color: #000;
}
&.hover-class {
background-color: rgb(108, 101, 244);
z-index: 100;
.hover-text {
color: #fff;
}
}
.icon-loading-text {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
user-select: none;
.icon-loading-image {
width: 48rpx;
height: 48rpx;
margin-right: 10rpx;
-webkit-touch-callout: none !important;
-webkit-user-select: none !important;
user-select: none !important;
}
.loading-text {
color: #666;
}
}
}
</style>