Commit 958ab12c authored by 水玉婷's avatar 水玉婷
Browse files

feat:语音转文字

parent 4a8d1aff
......@@ -42,13 +42,6 @@
<ChartComponent :chart-data="item.chartData" :chart-type="item.chartType || 'column'"
:title="item.chartData.title || '图表数据'" />
</div>
<!-- 音频内容块 -->
<template v-else-if="item.audioData">
<AudioPlayer
:src="item.audioData.src"
:duration-time="item.audioData.durationTime"
/>
</template>
<!-- 普通内容块 -->
<div v-else class="message-inner-box" @click="msg.messageType === 'sent' ? handleMessageClick(msg, item) : null">
<div v-html="item.content"></div>
......@@ -106,12 +99,12 @@
<div class="chat-input-container">
<div class="chat-input">
<!-- 语音识别按钮 -->
<VoiceRecognition ref="voiceRecognitionRef" :disabled="loading" :debug="true"
<VoiceRecognitionText ref="voiceRecognitionRef" :disabled="loading" :debug="true"
:token="props?.token"
:appCode="props?.appCode"
:apiBaseUrl="props?.apiBaseUrl"
@audio="handleVoiceAudio"
@error="handleVoiceError" class="voice-recognition-wrapper" />
@text="handleVoiceText"
@error="handleVoiceError" class="voice-recognition-wrapper" />
<textarea ref="textarea" v-model="messageText" placeholder="输入消息..." @keypress="handleKeyPress"
@input="adjustTextareaHeight"></textarea>
......@@ -133,8 +126,7 @@ import defaultAvatar from '@/assets/logo.png';
import rightIcon from '@/assets/right.svg'
import thinkIcon from '@/assets/think.svg'
import ChartComponent from './ChartComponent.vue'; // 导入独立的图表组件
import VoiceRecognition from './VoiceRecognition.vue'; // 导入语音识别组件
import AudioPlayer from './AudioPlayer.vue'; // 导入音频播放器组件
import VoiceRecognitionText from './VoiceRecognitionText.vue'; // 导入语音识别组件
import { createSSEService, type SSEData } from './utils/sseService'; // 导入SSE服务
import { createContentTemplateService, type Message } from './utils/contentTemplateService'; // 导入模板服务
......@@ -308,10 +300,10 @@ const sseService = createSSEService({
// 创建模板服务实例
const templateService = createContentTemplateService();
// 语音事件处理函数 - 修改为接收服务器返回的URL
const handleVoiceAudio = (audioUrl: string, audioBlob?: Blob, durationTime?: number) => {
// 直接使用统一的sendMessage函数发送音频消息
sendMessage('audio', { audioUrl, durationTime });
// 语音转文字事件处理函数
const handleVoiceText = (textMessage: string) => {
// 直接使用统一的sendMessage函数发送文本消息
sendMessage('text', { message: textMessage });
};
const handleVoiceError = (error: string) => {
......@@ -328,7 +320,7 @@ const startConversation = () => {
};
// 定义消息类型
type MessageType = 'text' | 'audio' | 'image' | 'file' | 'video';
type MessageType = 'text' | 'audio';
// 定义消息参数接口
interface MessageParams {
......
<template>
<div
class="voice-recognition"
:class="{ 'full-screen': isFullScreen }"
>
<!-- 语音按钮 -->
<button
class="voice-btn"
:class="{
'recording': isRecording,
'disabled': disabled
}"
@click="handleClick"
@mousedown="handleMouseDown"
@mouseup="stopRecording"
@mouseleave="stopRecording"
@touchstart="handleTouchStart"
@touchend="stopRecording"
@touchcancel="stopRecording"
:disabled="disabled"
:title="getButtonTitle"
>
<!-- 默认模式显示语音图标 -->
<span v-if="!isFullScreen" class="voice-icon">
<AudioOutlined />
</span>
<!-- 全屏模式只显示文字 -->
<span v-if="isFullScreen" class="full-screen-text">
{{ isRecording ? `正在说话 ${formattedDuration}` : '按住说话' }}
</span>
<!-- 录音时显示最大时长提示 -->
<span v-if="isRecording" class="max-duration-hint">
最长{{ maxDuration }}
</span>
<!-- 录音时显示指示器 -->
<span v-if="isRecording" class="recording-indicator">
<span class="wave-bar wave-bar-1"></span>
<span class="wave-bar wave-bar-2"></span>
<span class="wave-bar wave-bar-3"></span>
<span class="wave-bar wave-bar-4"></span>
<span class="wave-bar wave-bar-5"></span>
</span>
</button>
<!-- 移除取消按钮 -->
</div>
</template>
<script setup lang="ts">
import { ref, computed, onUnmounted, nextTick } from 'vue'
import { AudioOutlined } from '@ant-design/icons-vue'
// 组件属性
interface Props {
disabled?: boolean
debug?: boolean
maxDuration?: number, // 添加最大时长参数
token?: string,
appCode?: string,
apiBaseUrl?: string,
}
const props = withDefaults(defineProps<Props>(), {
disabled: false,
debug: false,
maxDuration: 30, // 默认最大时长为30秒
token: '',
appCode: '',
apiBaseUrl: '',
})
// 组件事件
const emit = defineEmits<{
audio: [message: string]
error: [error: string]
recordingStart: []
recordingStop: []
}>()
// 响应式数据
const isRecording = ref(false)
const isFullScreen = ref(false)
const recordingDuration = ref(0) // 录音时长(秒)
const recordingTimer = ref<NodeJS.Timeout | null>(null) // 计时器
// MediaRecorder相关
const mediaRecorder = ref<MediaRecorder | null>(null)
const audioChunks = ref<Blob[]>([])
const audioStream = ref<MediaStream | null>(null)
// 全局音频流缓存(单例模式)
let globalAudioStream: MediaStream | null = null
// 获取或创建音频流(单例模式)
const getOrCreateAudioStream = async (): Promise<MediaStream> => {
// 检查是否有活跃的音频流
if (globalAudioStream) {
// 检查流是否仍然活跃
const activeTracks = globalAudioStream.getAudioTracks().filter(track => track.readyState === 'live')
if (activeTracks.length > 0) {
if (props.debug) {
console.log('复用现有音频流')
}
return globalAudioStream
} else {
// 流已失效,清理缓存
globalAudioStream = null
}
}
// 创建新的音频流
if (props.debug) {
console.log('创建新的音频流')
}
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
sampleRate: 44100
}
})
// 缓存音频流
globalAudioStream = stream
// 监听轨道结束事件,清理缓存
stream.getAudioTracks().forEach(track => {
track.addEventListener('ended', () => {
if (globalAudioStream === stream) {
globalAudioStream = null
if (props.debug) {
console.log('音频流已结束,清理缓存')
}
}
})
})
return stream
}
// 格式化录音时长显示(分:秒)
const formatDuration = (seconds: number) => {
const minutes = Math.floor(seconds / 60)
const remainingSeconds = seconds % 60
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`
}
// 计算属性:格式化录音时长显示
const formattedDuration = computed(() => {
return formatDuration(recordingDuration.value)
})
// 计算属性:最大录音时长显示
const maxDuration = computed(() => {
return props.maxDuration
})
// 计算按钮标题
const getButtonTitle = computed(() => {
if (isRecording.value) return '松开停止'
if (isFullScreen.value) return '按住说话'
return '点击开始说话'
})
// 切换全屏模式
const toggleFullScreen = () => {
if (props.disabled || isRecording.value) return
if (!isFullScreen.value) {
// 进入全屏模式
isFullScreen.value = true
}
// 如果已经在全屏模式,点击事件由mousedown处理
}
// 退出全屏模式
const exitFullScreen = () => {
if (isRecording.value) {
stopRecording()
}
isFullScreen.value = false
}
// 点击事件处理
const handleClick = () => {
if (props.disabled || isRecording.value) return
// 如果不在全屏模式,进入全屏模式
if (!isFullScreen.value) {
isFullScreen.value = true
// 在企业微信中,进入全屏时异步预加载音频流
// 这样用户点击"按住说话"时就不会再弹出授权对话框
if (props.debug) {
console.log('进入全屏模式,异步预加载音频流')
}
// 异步预加载音频流,不阻塞UI
setTimeout(async () => {
try {
// 预加载音频流,但不开始录音
const stream = await getOrCreateAudioStream()
if (props.debug) {
console.log('音频流预加载完成')
}
// 预加载成功后,立即停止音频轨道,避免自动开始录音
// 但保持流缓存,这样用户点击"按住说话"时可以直接复用
stream.getAudioTracks().forEach(track => {
track.stop()
})
// 清理缓存,因为轨道已停止
globalAudioStream = null
if (props.debug) {
console.log('音频流预加载完成,轨道已停止,缓存已清理')
}
} catch (error) {
if (props.debug) {
console.warn('音频流预加载失败:', error)
}
// 预加载失败不影响后续操作,用户点击"按住说话"时会重新尝试
}
}, 0)
}
}
// 处理鼠标按下事件
const handleMouseDown = (event: MouseEvent) => {
if (props.debug) {
console.log('鼠标按下事件触发')
}
startRecording()
}
// 处理触摸开始事件
const handleTouchStart = (event: TouchEvent) => {
if (props.debug) {
console.log('触摸开始事件触发')
}
startRecording()
}
// 检查浏览器是否支持MediaRecorder
const isMediaRecorderSupported = () => {
const supported = 'MediaRecorder' in window;
if (props.debug) {
console.log('MediaRecorder支持检查:', supported);
}
return supported;
}
// 检查麦克风权限
const checkMicrophonePermission = async () => {
try {
const permissionStatus = await navigator.permissions.query({ name: 'microphone' as PermissionName });
if (props.debug) {
console.log('麦克风权限状态:', permissionStatus.state);
}
if (permissionStatus.state === 'denied') {
return false;
}
return true;
} catch (error) {
if (props.debug) {
console.warn('无法检查麦克风权限:', error);
}
return true; // 如果无法检查权限,继续尝试
}
}
// 开始录音
const startRecording = async () => {
if (props.disabled || isRecording.value || !isFullScreen.value) return
// 检查权限
const hasPermission = await checkMicrophonePermission();
if (!hasPermission) {
emit('error', '麦克风权限被拒绝');
exitFullScreen();
return;
}
// 检查浏览器支持
if (!isMediaRecorderSupported()) {
const errorMsg = '您的浏览器不支持音频录制功能';
emit('error', errorMsg);
exitFullScreen();
return;
}
try {
// 使用缓存音频流(单例模式)
const stream = await getOrCreateAudioStream();
audioStream.value = stream;
audioChunks.value = [];
// 创建MediaRecorder实例
mediaRecorder.value = new MediaRecorder(stream, {
mimeType: 'audio/webm;codecs=opus'
});
// 设置数据可用事件处理
mediaRecorder.value.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunks.value.push(event.data);
}
};
// 设置停止事件处理 - 录制完成后直接发送
mediaRecorder.value.onstop = () => {
sendRecordedAudio();
};
// 开始录制
mediaRecorder.value.start(100); // 每100ms收集一次数据
isRecording.value = true;
// 启动录音时长计时器
startRecordingTimer();
// 通知父组件开始录音
emit('recordingStart');
if (props.debug) {
console.log('开始录音,MediaRecorder状态:', mediaRecorder.value.state);
}
} catch (error) {
console.error('启动录音失败:', error);
let errorMessage = '无法启动录音';
if (error && error.name === 'NotAllowedError') {
errorMessage = '麦克风权限被拒绝';
} else if (error && error.name === 'NotFoundError') {
errorMessage = '未找到麦克风设备';
}
emit('error', errorMessage);
exitFullScreen(); // 出错时退出全屏
}
}
// 启动录音计时器
const startRecordingTimer = () => {
recordingDuration.value = 0;
recordingTimer.value = setInterval(() => {
recordingDuration.value += 1;
// 检查是否达到最大录音时长
if (recordingDuration.value >= props.maxDuration) {
stopRecording(); // 达到最大时长自动停止录音
}
}, 1000);
}
// 停止录音计时器
const stopRecordingTimer = () => {
if (recordingTimer.value) {
clearInterval(recordingTimer.value);
recordingTimer.value = null;
}
}
// 停止录音
const stopRecording = async () => {
if (!isRecording.value) return
if (mediaRecorder.value && mediaRecorder.value.state === 'recording') {
mediaRecorder.value.stop();
isRecording.value = false;
// 停止录音计时器
stopRecordingTimer();
// 停止音频轨道,释放麦克风权限
if (audioStream.value) {
audioStream.value.getTracks().forEach(track => track.stop());
audioStream.value = null;
}
// 等待Vue完成DOM更新后再退出全屏
await nextTick();
exitFullScreen();
// 通知父组件停止录音
emit('recordingStop');
if (props.debug) {
console.log('停止录音,MediaRecorder状态:', mediaRecorder.value.state);
console.log('录音时长:', recordingDuration.value, '');
}
}
}
// 上传音频文件到服务器进行语音转文字
const uploadAudioFile = async (audioBlob: Blob): Promise<string> => {
try {
const formData = new FormData();
formData.append('file', audioBlob, 'recording.wav');
formData.append('fileFolder', 'AI_TEMP');
const response = await fetch(`/agentService/index/audio2txt`, {
method: 'POST',
headers: {
'x-app-code': props.appCode,
'token': props.token,
'x-session-id': props.token,
},
body: formData
});
// 解析响应体为JSON
const result = await response.json();
if (result.code === 0) {
const {question} = result.data;
return question;
} else {
throw new Error('语音转文字接口返回数据格式错误');
}
} catch (error) {
console.error('语音转文字失败:', error);
throw error;
}
}
// 发送录制的音频(转换为文本消息)
const sendRecordedAudio = async () => {
// 防误触检查:只有录音时长超过0.5秒才发送
if (recordingDuration.value < 0.5) {
if (props.debug) {
console.log('录音时长过短,视为误触,不发送数据。时长:', recordingDuration.value, '');
}
// 清理录音数据并退出全屏
audioChunks.value = [];
exitFullScreen();
return;
}
if (audioChunks.value.length === 0) {
emit('error', '录音数据为空');
exitFullScreen(); // 数据为空时退出全屏
return;
}
const audioBlob = new Blob(audioChunks.value, { type: 'audio/webm;codecs=opus' });
try {
// 调用语音转文字接口获取转换后的文本
const textMessage = await uploadAudioFile(audioBlob);
// 上传成功后触发text事件,传递转换后的文本
emit('text', textMessage);
if (props.debug) {
console.log('语音转文字成功,文本内容:', textMessage);
console.log('音频大小:', Math.round(audioBlob.size / 1024), 'KB');
console.log('音频时长:', recordingDuration.value, '');
}
} catch (error) {
console.error('语音转文字失败:', error);
const errorMsg = '语音转文字失败,请重试';
emit('error', errorMsg);
} finally {
// 清理录音数据并退出全屏
audioChunks.value = [];
// 清理音频流,释放麦克风权限(重要:解决企业微信中麦克风一直开启的问题)
// 但保留全局缓存,以便后续录音可以复用
if (audioStream.value) {
// 只停止轨道,不清理全局缓存,以便后续复用
audioStream.value.getTracks().forEach(track => track.stop());
audioStream.value = null;
if (props.debug) {
console.log('消息发送完成:音频流轨道已停止,麦克风权限已释放');
}
}
exitFullScreen();
}
}
// 组件卸载时清理资源
onUnmounted(() => {
if (isRecording.value) {
stopRecording();
}
// 清理当前录音音频流
if (audioStream.value) {
audioStream.value.getTracks().forEach(track => track.stop());
}
// 清理全局缓存音频流(重要:防止权限占用)
if (globalAudioStream) {
globalAudioStream.getTracks().forEach(track => track.stop());
globalAudioStream = null;
if (props.debug) {
console.log('组件卸载:全局音频流已清理');
}
}
// 清理计时器
stopRecordingTimer();
})
// 暴露方法给父组件
defineExpose({
startRecording,
stopRecording,
isRecording: () => isRecording.value,
enterFullScreen: toggleFullScreen,
exitFullScreen
})
</script>
<style scoped lang="less">
@import './style.less';
.voice-recognition {
position: relative;
display: inline-flex;
align-items: center;
gap: 10px;
transition: all 0.3s ease;
// 全屏模式样式 - 浅色系蓝色圆角主题
&.full-screen {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: @blue-light-1; // 使用浅蓝色背景
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 9999 !important;
.voice-btn {
width: 100%;
height: 100%;
font-size: 32px;
flex-direction: row; // 改为水平排列
justify-content: center;
align-items: center; // 垂直居中
gap: 12px; // 水平间距
background: @primary-color !important; // 按钮使用主色调蓝色
border: none;
border-radius: 12px;
right:0;
.full-screen-text {
opacity: 1;
font-size: 18px;
color: white;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
white-space: nowrap; // 防止文字换行
}
}
// 录音状态样式 - 使用波纹动画
.voice-btn.recording {
background: @primary-hover; // 使用hover颜色
.full-screen-text {
color: #fff;
}
.recording-indicator .wave-bar {
animation: wechatWaveAnimation 1.2s ease-in-out infinite;
&.wave-bar-1 {
animation-delay: 0s;
animation-duration: 1.4s;
}
&.wave-bar-2 {
animation-delay: 0.2s;
animation-duration: 1.2s;
}
&.wave-bar-3 {
animation-delay: 0.4s;
animation-duration: 1.0s;
}
}
}
.recording-indicator {
display: flex;
align-items: flex-end;
gap: 1px;
height: 16px;
.wave-bar {
width: 3px;
background: #fff;
border-radius: 2px 2px 0 0;
transition: all 0.3s ease;
&.wave-bar-1 {
height: 4px;
border-radius: 3px 3px 0 0;
}
&.wave-bar-2 {
height: 8px;
border-radius: 3px 3px 0 0;
}
&.wave-bar-3 {
height: 12px;
border-radius: 3px 3px 0 0;
}
&.wave-bar-4 {
height: 8px;
border-radius: 3px 3px 0 0;
}
&.wave-bar-5 {
height: 4px;
border-radius: 3px 3px 0 0;
}
}
}
}
}
.voice-btn {
width: 40px;
height: 40px;
border: none;
border-radius: 50%;
right:14px;
color: @primary-color;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
position: relative;
z-index: 100;
background: transparent !important;
// 禁止用户选择文本
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
&.recording {
background: @primary-color;
top:0 !important;
transform: none;
}
&.disabled {
color: @gray-4;
cursor: not-allowed;
}
}
.voice-icon {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
font-size: 18px;
}
.full-screen-text {
font-size: 16px;
opacity: 0;
transition: opacity 0.3s ease;
}
.recording-indicator {
gap: 2px;
}
// 波纹动画 - 与AiChat.vue中的动画保持一致
@keyframes wechatWaveAnimation {
0%, 100% {
transform: scaleY(0.3);
opacity: 0.6;
}
25% {
transform: scaleY(0.7);
opacity: 0.8;
}
50% {
transform: scaleY(1);
opacity: 1;
}
75% {
transform: scaleY(0.7);
opacity: 0.8;
}
}
// 最大时长提示样式
.max-duration-hint {
font-size: 12px;
color: rgba(255, 255, 255, 0.7);
margin-top: 4px;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
</style>
\ No newline at end of file
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment