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 0000000..93251a0 Binary files /dev/null and b/static/icon/audio-fetching.png differ