// 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 } }