From 8f1dbc28f7f5a2366ff4b1eeca0456bbdc5e1bca Mon Sep 17 00:00:00 2001 From: xiebing Date: Mon, 22 Dec 2025 17:46:40 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E6=96=87=E5=AD=97=E8=BD=AC=E8=AF=AD?= =?UTF-8?q?=E9=9F=B3=E6=94=B9=E4=B8=BA=E7=89=87=E6=AE=B5=E8=AF=B7=E6=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hook/useAudioSpeak.js | 664 +++++++++++++++++++++++++ pages/chat/components/ai-paging.vue | 20 +- pages/chat/components/uploadQrcode.vue | 5 +- static/icon/audio-fetching.png | Bin 0 -> 13436 bytes 4 files changed, 684 insertions(+), 5 deletions(-) create mode 100644 hook/useAudioSpeak.js create mode 100644 static/icon/audio-fetching.png diff --git a/hook/useAudioSpeak.js b/hook/useAudioSpeak.js new file mode 100644 index 0000000..0f43d67 --- /dev/null +++ b/hook/useAudioSpeak.js @@ -0,0 +1,664 @@ +// useAudioSpeak.js +import { ref } from 'vue' + +/** + * TTS语音合成Hook + * @param {Object} config - TTS配置 + * @param {string} config.apiUrl - 语音合成API地址 + * @param {number} config.maxSegmentLength - 最大分段长度 + * @returns {Object} TTS相关方法和状态 + */ +export const useAudioSpeak = (config = {}) => { + const { + apiUrl = 'http://39.98.44.136:19527/synthesize', + maxSegmentLength = 30 + } = config + + // 状态 + const isSpeaking = ref(false) + const isPaused = ref(false) + const isLoading = ref(false) + + // 播放状态 + const currentText = ref('') + const currentSegmentIndex = ref(0) + const totalSegments = ref(0) + const progress = ref(0) + + // 音频相关 + let audioContext = null + let audioSource = null + let audioQueue = [] // 播放队列 [{blob, segmentIndex, text, isMerged?}, ...] + let isPlayingQueue = false + let isCancelled = false + let segments = [] + let allRequestsCompleted = false + let pendingMergeSegments = [] // 存储所有片段的原始数据 [{arrayBuffer, segmentIndex}] + let firstSegmentHeader = null + let lastPlayedIndex = -1 + let currentPlayingIndex = -1 // 当前正在播放的片段索引 + + // 按标点分割文本 + const splitTextByPunctuation = (text) => { + const segments = [] + const punctuation = /[,。!?;!?;\n]/g + let lastIndex = 0 + + while (true) { + const match = punctuation.exec(text) + if (!match) break + + const isChinesePunctuation = /[,。!?;]/.test(match[0]) + const endIndex = isChinesePunctuation ? match.index + 1 : match.index + + const segment = text.substring(lastIndex, endIndex) + if (segment.trim()) { + segments.push(segment.trim()) + } + lastIndex = endIndex + } + + const lastSegment = text.substring(lastIndex) + if (lastSegment.trim()) { + segments.push(lastSegment.trim()) + } + + return segments + } + + // 预处理文本 + const preprocessText = (text) => { + if (!text || typeof text !== 'string') return [] + + const cleanText = text.replace(/\s+/g, ' ').trim() + let segments = splitTextByPunctuation(cleanText) + + const finalSegments = [] + segments.forEach(segment => { + if (segment.length <= maxSegmentLength) { + finalSegments.push(segment) + } else { + for (let i = 0; i < segment.length; i += maxSegmentLength) { + finalSegments.push(segment.substring(i, i + maxSegmentLength)) + } + } + }) + + return finalSegments.filter(seg => seg && seg.trim()) + } + + // 检测WAV头部大小 + const detectWavHeaderSize = (arrayBuffer) => { + try { + const header = new Uint8Array(arrayBuffer.slice(0, 100)) + + if (header[0] === 0x52 && header[1] === 0x49 && header[2] === 0x46 && header[3] === 0x46) { + for (let i = 36; i < 60; i++) { + if (header[i] === 0x64 && header[i+1] === 0x61 && header[i+2] === 0x74 && header[i+3] === 0x61) { + return i + 8 + } + } + } + + return 44 + } catch (error) { + console.error('检测WAV头部大小失败:', error) + return 44 + } + } + + // 请求音频片段 + const fetchAudioSegment = async (text, index) => { + try { + console.log(`📶正在请求第${index + 1}段音频: "${text}"`) + + const response = await fetch(apiUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + text: text, + speed: 1.0, + volume: 1.0, + pitch: 1.0, + voice_type: 1 + }) + }) + + if (!response.ok) { + throw new Error(`HTTP错误! 状态码: ${response.status}`) + } + + const audioBlob = await response.blob() + + if (!audioBlob || audioBlob.size < 100) { + throw new Error('音频数据太小或无效') + } + + console.log(`第${index + 1}段音频获取成功,大小: ${audioBlob.size} 字节`) + + const arrayBuffer = await audioBlob.arrayBuffer() + + // 保存原始数据用于可能的合并 + pendingMergeSegments.push({ + arrayBuffer: arrayBuffer, + segmentIndex: index + }) + + // 如果是第一个片段,保存其WAV头部用于合并 + if (index === 0) { + const headerSize = detectWavHeaderSize(arrayBuffer) + firstSegmentHeader = new Uint8Array(arrayBuffer.slice(0, headerSize)) + } + + return { + blob: audioBlob, + segmentIndex: index, + text: text, + arrayBuffer: arrayBuffer + } + + } catch (error) { + console.error(`获取第${index + 1}段音频失败:`, error) + throw error + } + } + + // 初始化音频上下文 + const initAudioContext = () => { + if (!audioContext) { + audioContext = new (window.AudioContext || window.webkitAudioContext)() + console.log('音频上下文已初始化') + } + return audioContext + } + + // 解码并播放音频Blob + const decodeAndPlayBlob = async (audioBlob, segmentIndex) => { + return new Promise((resolve, reject) => { + if (isCancelled || !audioContext) { + resolve() + return + } + + // 设置当前正在播放的片段索引 + currentPlayingIndex = segmentIndex + console.log(`设置当前播放索引为: ${currentPlayingIndex}`) + + const fileReader = new FileReader() + + fileReader.onload = async (e) => { + try { + const arrayBuffer = e.target.result + const audioBuffer = await audioContext.decodeAudioData(arrayBuffer) + + audioSource = audioContext.createBufferSource() + audioSource.buffer = audioBuffer + audioSource.connect(audioContext.destination) + + audioSource.onended = () => { + console.log(`第${segmentIndex + 1}个片段播放完成`) + audioSource = null + lastPlayedIndex = segmentIndex + currentPlayingIndex = -1 + resolve() + } + + audioSource.onerror = (error) => { + console.error('音频播放错误:', error) + currentPlayingIndex = -1 + reject(error) + } + + console.log(`▶️开始播放第${segmentIndex + 1}个片段`) + audioSource.start(0) + + } catch (error) { + console.error('解码或播放音频失败:', error) + currentPlayingIndex = -1 + reject(error) + } + } + + fileReader.onerror = (error) => { + console.error('读取音频文件失败:', error) + currentPlayingIndex = -1 + reject(error) + } + + fileReader.readAsArrayBuffer(audioBlob) + }) + } + + // 合并剩余音频片段 + const mergeRemainingSegments = (segmentsToMerge) => { + if (segmentsToMerge.length === 0 || !firstSegmentHeader) { + console.log('没有待合并的片段或缺少头部信息') + return null + } + + try { + // 按segmentIndex排序 + segmentsToMerge.sort((a, b) => a.segmentIndex - b.segmentIndex) + + console.log(`开始合并${segmentsToMerge.length}个剩余片段`) + + // 计算总数据大小 + let totalAudioDataSize = 0 + for (const segment of segmentsToMerge) { + const headerSize = detectWavHeaderSize(segment.arrayBuffer) + totalAudioDataSize += segment.arrayBuffer.byteLength - headerSize + } + + console.log(`剩余音频数据总大小: ${totalAudioDataSize}字节`) + + // 创建合并后的数组 + const headerSize = firstSegmentHeader.length + const totalSize = headerSize + totalAudioDataSize + const mergedArray = new Uint8Array(totalSize) + + // 设置头部 + mergedArray.set(firstSegmentHeader, 0) + + // 更新头部中的data大小 + const view = new DataView(mergedArray.buffer) + view.setUint32(40, totalAudioDataSize, true) + view.setUint32(4, 36 + totalAudioDataSize, true) + + // 合并所有音频数据 + let offset = headerSize + for (const segment of segmentsToMerge) { + const segmentHeaderSize = detectWavHeaderSize(segment.arrayBuffer) + const segmentData = new Uint8Array(segment.arrayBuffer.slice(segmentHeaderSize)) + mergedArray.set(segmentData, offset) + offset += segmentData.length + } + + console.log(`音频合并完成,总大小: ${mergedArray.length}字节`) + + // 创建Blob + return new Blob([mergedArray], { type: 'audio/wav' }) + + } catch (error) { + console.error('合并音频片段失败:', error) + return null + } + } + + // 从队列播放音频 + const playFromQueue = async () => { + if (isPlayingQueue || audioQueue.length === 0) { + return + } + + isPlayingQueue = true + + try { + while (audioQueue.length > 0 && !isCancelled) { + const audioItem = audioQueue[0] + currentSegmentIndex.value = audioItem.segmentIndex + + // 播放第一个音频 + console.log(`准备播放第${audioItem.segmentIndex + 1}个片段: "${audioItem.text}"`) + await decodeAndPlayBlob(audioItem.blob, audioItem.segmentIndex) + + // 播放完成后移除 + audioQueue.shift() + console.log(`片段${audioItem.segmentIndex + 1}播放完成,队列剩余: ${audioQueue.length}`) + + // 更新进度 + progress.value = Math.floor(((audioItem.segmentIndex + 1) / totalSegments.value) * 100) + + // 短暂延迟 + await new Promise(resolve => setTimeout(resolve, 50)) + } + + // 检查是否所有片段都播放完成 + if (audioQueue.length === 0 && lastPlayedIndex === totalSegments.value - 1) { + console.log('所有音频片段播放完成') + progress.value = 100 + } + + } catch (error) { + console.error('播放队列出错:', error) + } finally { + isPlayingQueue = false + } + } + + // 向队列添加音频 + const addToQueue = (audioItem) => { + // 按segmentIndex插入到正确位置 + let insertIndex = 0 + for (let i = audioQueue.length - 1; i >= 0; i--) { + if (audioQueue[i].segmentIndex < audioItem.segmentIndex) { + insertIndex = i + 1 + break + } + } + + audioQueue.splice(insertIndex, 0, audioItem) + console.log(`音频片段${audioItem.segmentIndex + 1}已添加到队列,队列长度: ${audioQueue.length}`) + + // 如果队列中有音频且当前没有在播放,开始播放 + if (!isPlayingQueue && audioQueue.length === 1) { + playFromQueue() + } + } + + // 尝试合并剩余片段 + const tryMergeRemainingSegments = () => { + if (!allRequestsCompleted) { + console.log('合并检查: 请求未完成,跳过') + return + } + + // 获取真正未播放的片段(不包括已经播放的和正在播放的) + const trulyUnplayedSegments = audioQueue.filter(item => { + // 排除已经播放完成的 + if (item.segmentIndex <= lastPlayedIndex) { + return false + } + // 排除当前正在播放的 + if (currentPlayingIndex !== -1 && item.segmentIndex === currentPlayingIndex) { + return false + } + return true + }) + + const shouldMerge = trulyUnplayedSegments.length > 1 + + console.log(`🔀合并检查: 已播放到=${lastPlayedIndex}, 正在播放=${currentPlayingIndex}, 真正未播放片段=${trulyUnplayedSegments.length}, 应该合并=${shouldMerge}`) + + if (!shouldMerge) { + console.log('不符合合并条件(真正未播放片段数量 <= 1)') + return + } + + console.log('✔️符合合并条件,开始合并剩余片段') + + // 获取这些片段的原始数据 + const segmentsToMergeData = [] + for (const item of trulyUnplayedSegments) { + const segmentData = pendingMergeSegments.find(s => s.segmentIndex === item.segmentIndex) + if (segmentData) { + segmentsToMergeData.push(segmentData) + } + } + + if (segmentsToMergeData.length === 0) { + console.log('没有找到待合并的原始数据') + return + } + + // 合并这些片段 + const mergedBlob = mergeRemainingSegments(segmentsToMergeData) + + if (mergedBlob) { + // 从audioQueue中移除这些将被合并的片段 + const segmentIndicesToRemove = trulyUnplayedSegments.map(s => s.segmentIndex) + + for (let i = audioQueue.length - 1; i >= 0; i--) { + if (segmentIndicesToRemove.includes(audioQueue[i].segmentIndex)) { + audioQueue.splice(i, 1) + } + } + + // 将合并后的音频添加到队列的合适位置 + const firstSegmentIndex = Math.min(...segmentIndicesToRemove) + const mergedText = trulyUnplayedSegments.map(s => s.text).join(' ') + + const mergedAudioItem = { + blob: mergedBlob, + segmentIndex: firstSegmentIndex, + text: mergedText, + isMerged: true + } + + // 插入到正确位置(按segmentIndex) + let insertIndex = 0 + for (let i = audioQueue.length - 1; i >= 0; i--) { + if (audioQueue[i].segmentIndex < firstSegmentIndex) { + insertIndex = i + 1 + break + } + } + + audioQueue.splice(insertIndex, 0, mergedAudioItem) + console.log(`合并后的音频已添加到队列位置${insertIndex},包含${trulyUnplayedSegments.length}个原始片段,队列长度: ${audioQueue.length}`) + } else { + console.log('合并失败,保持原始片段') + } + } + + // 文本提取工具函数 + function extractSpeechText(markdown) { + if (!markdown || markdown.indexOf('job-json') === -1) { + return markdown; + } + const jobRegex = /``` job-json\s*({[\s\S]*?})\s*```/g; + const jobs = []; + let match; + let lastJobEndIndex = 0; + let firstJobStartIndex = -1; + while ((match = jobRegex.exec(markdown)) !== null) { + const jobStr = match[1]; + try { + const job = JSON.parse(jobStr); + jobs.push(job); + if (firstJobStartIndex === -1) { + firstJobStartIndex = match.index; + } + lastJobEndIndex = jobRegex.lastIndex; + } catch (e) { + console.warn('JSON 解析失败', e); + } + } + const guideText = firstJobStartIndex > 0 ? + markdown.slice(0, firstJobStartIndex).trim() : ''; + const endingText = lastJobEndIndex < markdown.length ? + markdown.slice(lastJobEndIndex).trim() : ''; + const jobTexts = jobs.map((job, index) => { + return `第 ${index + 1} 个岗位,岗位名称是:${job.jobTitle},公司是:${job.companyName},薪资:${job.salary},地点:${job.location},学历要求:${job.education},经验要求:${job.experience}。`; + }); + const finalTextParts = []; + if (guideText) finalTextParts.push(guideText); + finalTextParts.push(...jobTexts); + if (endingText) finalTextParts.push(endingText); + return finalTextParts.join('\n'); + } + + // 清理资源 + const cleanup = () => { + isCancelled = true + + if (audioSource) { + try { + audioSource.stop() + } catch (e) { + // 忽略错误 + } + audioSource = null + } + + if (audioContext && audioContext.state !== 'closed') { + audioContext.close() + } + + audioContext = null + audioQueue = [] + segments = [] + isPlayingQueue = false + allRequestsCompleted = false + pendingMergeSegments = [] + firstSegmentHeader = null + lastPlayedIndex = -1 + currentPlayingIndex = -1 + + isSpeaking.value = false + isPaused.value = false + isLoading.value = false + progress.value = 0 + } + + // 停止播放 + const stopAudio = () => { + isCancelled = true + if (audioSource) { + try { + audioSource.stop() + } catch (e) { + // 忽略错误 + } + audioSource = null + } + isPlayingQueue = false + currentPlayingIndex = -1 + isSpeaking.value = false + isPaused.value = false + isLoading.value = false + } + + // 主speak方法 + const speak = async (text) => { + text = extractSpeechText(text) + console.log('开始语音播报:', text) + + // 如果正在播放,先停止 + if (isSpeaking.value) { + stopAudio() + } + + // 重置状态 + isCancelled = false + currentText.value = text + isLoading.value = true + isSpeaking.value = true + progress.value = 0 + audioQueue = [] + allRequestsCompleted = false + pendingMergeSegments = [] + firstSegmentHeader = null + lastPlayedIndex = -1 + currentPlayingIndex = -1 + + // 预处理文本 + segments = preprocessText(text) + console.log('文本分段结果:', segments) + + if (segments.length === 0) { + console.warn('没有有效的文本可以播报') + isLoading.value = false + isSpeaking.value = false + return + } + + totalSegments.value = segments.length + + try { + // 初始化音频上下文 + initAudioContext() + + // 1. 串行请求所有音频片段 + for (let i = 0; i < segments.length; i++) { + if (isCancelled) break + + console.log(`串行请求第${i + 1}/${segments.length}个片段`) + + // 更新进度(请求进度) + progress.value = Math.floor(((i + 1) / segments.length) * 50) + + // 请求音频片段 + const audioItem = await fetchAudioSegment(segments[i], i) + + // 添加到播放队列 + addToQueue({ + blob: audioItem.blob, + segmentIndex: i, + text: segments[i] + }) + + // 如果是第一个片段,取消loading状态 + if (i === 0 && isLoading.value) { + console.log('第一个音频片段已就绪,开始播放') + isLoading.value = false + } + } + + // 2. 所有请求完成 + console.log('所有音频片段请求完成') + allRequestsCompleted = true + + // 3. 立即检查是否可以合并剩余片段 + tryMergeRemainingSegments() + + // 4. 等待所有音频播放完成 + while (audioQueue.length > 0 && !isCancelled) { + await new Promise(resolve => setTimeout(resolve, 100)) + } + + console.log('音频播放完成') + + } catch (error) { + console.error('语音播报失败:', error) + } finally { + // 最终清理 + if (isCancelled) { + cleanup() + } else { + isSpeaking.value = false + isPaused.value = false + isLoading.value = false + progress.value = 100 + } + } + } + + // 暂停播放 + const pause = () => { + if (audioContext && isSpeaking.value && !isPaused.value) { + audioContext.suspend().then(() => { + isPaused.value = true + console.log('播放已暂停') + }) + } + } + + // 恢复播放 + const resume = () => { + if (audioContext && isSpeaking.value && isPaused.value) { + audioContext.resume().then(() => { + isPaused.value = false + console.log('播放已恢复') + }) + } + } + + // 取消音频 + const cancelAudio = () => { + console.log('取消音频播放') + stopAudio() + cleanup() + } + + // 组件卸载时清理 + + return { + // 状态 + isSpeaking, + isPaused, + isLoading, + currentText, + currentSegmentIndex, + totalSegments, + progress, + + // 方法 + speak, + pause, + resume, + cancelAudio, + cleanup + } +} \ No newline at end of file diff --git a/pages/chat/components/ai-paging.vue b/pages/chat/components/ai-paging.vue index 4c66fd8..097d7c1 100644 --- a/pages/chat/components/ai-paging.vue +++ b/pages/chat/components/ai-paging.vue @@ -78,6 +78,12 @@ src="/static/icon/stop.png" @click="stopMarkdown(msg.displayText, index)" > + { //还剩多少文件可以上传 给扫码 onMounted(async () => { changeQueries(); scrollToBottom(); - isAudioPermission.value = await requestMicPermission(); -}); + isAudioPermission.value = await requestMicPermission(); +}) + +onUnmounted(()=>{ + console.log('清理TTS资源') + cleanup() +}) + const requestMicPermission = async () => { try { diff --git a/pages/chat/components/uploadQrcode.vue b/pages/chat/components/uploadQrcode.vue index 6ed3e49..f7b284a 100644 --- a/pages/chat/components/uploadQrcode.vue +++ b/pages/chat/components/uploadQrcode.vue @@ -119,13 +119,16 @@ function preViewImage(file) { async function delFile(file, idx) { deleting.value = true; try { - await $api.createRequest(`/app/kiosk/${file.id}`, {sessionId: uuid.value,ids: [file.id]}, 'delete', true); + await $api.createRequest(`/app/kiosk/remove?sessionId=${uuid.value}&ids=${file.id}`, {}, 'post', true); } catch (error) { $api.msg(error); } finally { deleting.value = false; } fileList.value.splice(idx, 1); + if(fileList.value.length == 0){ + open() + } } function open() { diff --git a/static/icon/audio-fetching.png b/static/icon/audio-fetching.png new file mode 100644 index 0000000000000000000000000000000000000000..93251a042394c7e10fa2bced38564735eec0d085 GIT binary patch literal 13436 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 literal 0 HcmV?d00001