// useAudioSpeak.js import { ref } from 'vue' import globalConfig from '@/config.js'; import useUserStore from '@/stores/useUserStore'; /** * TTS语音合成Hook * @param {Object} config - TTS配置 * @param {string} config.apiUrl - 语音合成API地址 * @param {number} config.maxSegmentLength - 最大分段长度 * @param {number} config.minQueueSize - 最小队列缓存数量 * @returns {Object} TTS相关方法和状态 */ export const useAudioSpeak = (config = {}) => { const { apiUrl = `${globalConfig.baseUrl}/app/speech/tts`, maxSegmentLength = 30, minQueueSize = 3 // 最小队列缓存数量 } = 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, isProcessing?}, ...] let isPlayingQueue = false let isCancelled = false let segments = [] let lastPlayedIndex = -1 let currentPlayingIndex = -1 // 当前正在播放的片段索引 let isInterrupted = false // 是否被中断 let segmentRequests = new Map() // 存储正在进行中的请求 {index: promise} // 按标点分割文本 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()) } // 请求音频片段 const fetchAudioSegment = async (text, index) => { try { console.log(`📶正在请求第${index + 1}段音频: "${text}"`) let Authorization = '' if (useUserStore().token) { Authorization = `${useUserStore().token}` } const response = await fetch(`${apiUrl}?text=${text}`, { method: 'get', headers: { 'Content-Type': 'application/json', 'Authorization': encodeURIComponent(Authorization) }, }) 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} 字节`) return { blob: audioBlob, segmentIndex: index, text: text, isProcessing: false } } catch (error) { console.error(`获取第${index + 1}段音频失败:`, error) throw error } finally { // 请求完成,从请求映射中移除 segmentRequests.delete(index) } } // 预加载音频片段(填充队列) const preloadAudioSegments = async (startIndex) => { if (isCancelled || isInterrupted) { console.log('播放已中断,停止预加载') return } // 计算需要预加载的片段 const neededSegments = [] for (let i = startIndex; i < Math.min(startIndex + minQueueSize, segments.length); i++) { // 检查是否已经在队列中或正在请求中 const isInQueue = audioQueue.some(item => item.segmentIndex === i) const isRequesting = segmentRequests.has(i) if (!isInQueue && !isRequesting && i > lastPlayedIndex) { neededSegments.push(i) } } if (neededSegments.length === 0) { console.log('无需预加载,所有需要的片段已在队列或请求中') return } console.log(`预加载 ${neededSegments.length} 个片段: ${neededSegments.map(i => i + 1).join(', ')}`) // 并发请求需要的片段 const requests = neededSegments.map(index => { console.log(`开始预加载第${index + 1}个片段`) segmentRequests.set(index, true) // 标记为正在请求 return fetchAudioSegment(segments[index], index) .then(audioItem => { if (isCancelled || isInterrupted) { console.log('播放已中断,丢弃预加载的片段') return null } // 添加到队列 addToQueue(audioItem) console.log(`预加载片段${index + 1}完成,队列长度: ${audioQueue.length}`) return audioItem }) .catch(error => { console.error(`预加载片段${index + 1}失败:`, error) return null }) }) await Promise.all(requests) } // 初始化音频上下文 const initAudioContext = () => { if (!audioContext || audioContext.state === 'closed') { audioContext = new (window.AudioContext || window.webkitAudioContext)() console.log('音频上下文已初始化') } return audioContext } // 解码并播放音频Blob const decodeAndPlayBlob = async (audioBlob, segmentIndex) => { return new Promise((resolve, reject) => { if (isCancelled || !audioContext) { console.log('播放已取消或音频上下文不存在,跳过播放') 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) // 如果在此期间被取消,直接返回 if (isCancelled) { console.log('播放过程中被取消') resolve() return } audioSource = audioContext.createBufferSource() audioSource.buffer = audioBuffer audioSource.connect(audioContext.destination) audioSource.onended = () => { console.log(`第${segmentIndex + 1}个片段播放完成`) audioSource = null lastPlayedIndex = segmentIndex currentPlayingIndex = -1 // 片段播放完成后,检查是否需要预加载更多 checkAndPreloadNextSegments() resolve() } audioSource.onerror = (error) => { console.error('音频播放错误:', error) currentPlayingIndex = -1 reject(error) } console.log(`▶️开始播放第${segmentIndex + 1}个片段`) // 如果音频上下文被暂停,先恢复 if (audioContext.state === 'suspended') { await audioContext.resume() } 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 checkAndPreloadNextSegments = () => { if (isCancelled || isInterrupted) { return } // 计算当前队列中未播放的片段数量 const unplayedInQueue = audioQueue.filter(item => item.segmentIndex > lastPlayedIndex).length console.log(`队列检查: 已播放=${lastPlayedIndex + 1}, 队列中未播放=${unplayedInQueue}, 最小队列要求=${minQueueSize}`) // 如果队列中的未播放片段少于最小要求,预加载更多 if (unplayedInQueue < minQueueSize && lastPlayedIndex < segments.length - 1) { const nextIndex = Math.max(lastPlayedIndex + 1, 0) console.log(`队列不足,开始预加载从第${nextIndex + 1}个片段开始`) preloadAudioSegments(nextIndex) } } // 从队列播放音频 const playFromQueue = async () => { if (isPlayingQueue || audioQueue.length === 0) { return } isPlayingQueue = true try { while (audioQueue.length > 0 && !isCancelled && !isInterrupted) { // 找到下一个应该播放的片段(按segmentIndex顺序) const nextItemIndex = audioQueue.findIndex(item => item.segmentIndex === lastPlayedIndex + 1) if (nextItemIndex === -1) { // 没有找到下一个片段,等待一下再检查 console.log(`等待下一个片段(需要${lastPlayedIndex + 2}),当前队列:`, audioQueue.map(item => item.segmentIndex + 1)) await new Promise(resolve => setTimeout(resolve, 100)) continue } const audioItem = audioQueue[nextItemIndex] currentSegmentIndex.value = audioItem.segmentIndex // 播放音频 console.log(`准备播放第${audioItem.segmentIndex + 1}个片段: "${audioItem.text}"`) await decodeAndPlayBlob(audioItem.blob, audioItem.segmentIndex) if (isCancelled || isInterrupted) { console.log('播放被中断,退出播放队列') break } // 播放完成后从队列中移除 audioQueue.splice(nextItemIndex, 1) 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 (lastPlayedIndex === totalSegments.value - 1) { console.log('所有音频片段播放完成') progress.value = 100 isSpeaking.value = false isPaused.value = false } } catch (error) { console.error('播放队列出错:', error) } finally { isPlayingQueue = false } } // 向队列添加音频 const addToQueue = (audioItem) => { // 如果被取消或中断,不添加到队列 if (isCancelled || isInterrupted) { console.log('播放已中断,不添加到队列') return } // 如果已经播放过或正在播放,不添加 if (audioItem.segmentIndex <= lastPlayedIndex) { console.log(`片段${audioItem.segmentIndex + 1}已经播放过,跳过添加`) return } // 检查是否已经在队列中 const exists = audioQueue.some(item => item.segmentIndex === audioItem.segmentIndex) if (exists) { console.log(`片段${audioItem.segmentIndex + 1}已经在队列中,跳过添加`) return } // 按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}已添加到队列位置${insertIndex},队列长度: ${audioQueue.length}`) // 如果队列中有音频且当前没有在播放,开始播放 if (!isPlayingQueue && audioQueue.some(item => item.segmentIndex === 0)) { playFromQueue() } } // 文本提取工具函数 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) => { // 处理薪资格式 let salaryText = job.salary; if (salaryText) { // 匹配 "XXXXX-XXXXX元/月" 格式 const rangeMatch = salaryText.match(/(\d+)-(\d+)元\/月/); if (rangeMatch) { const minSalary = parseInt(rangeMatch[1], 10); const maxSalary = parseInt(rangeMatch[2], 10); // 转换为千位单位 const minK = Math.round(minSalary / 1000); const maxK = Math.round(maxSalary / 1000); salaryText = `${minK}千到${maxK}千每月`; } // 如果不是 "XXXXX-XXXXX元/月" 格式,保持原样 } return `第 ${index + 1} 个岗位,岗位名称是:${job.jobTitle},公司是:${job.companyName},薪资:${salaryText},地点:${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 = () => { console.log('开始清理资源') isCancelled = true isInterrupted = true if (audioSource) { try { audioSource.stop() console.log('音频源已停止') } catch (e) { console.warn('停止音频源失败:', e) } audioSource = null } if (audioContext && audioContext.state !== 'closed') { audioContext.close() console.log('音频上下文已关闭') } // 取消所有进行中的请求 segmentRequests.clear() audioContext = null audioQueue = [] segments = [] isPlayingQueue = false lastPlayedIndex = -1 currentPlayingIndex = -1 isSpeaking.value = false isPaused.value = false isLoading.value = false progress.value = 0 console.log('资源清理完成') } // 停止播放 const stopAudio = () => { console.log('停止音频播放') isCancelled = true isInterrupted = true if (audioSource) { try { audioSource.stop() console.log('音频源已停止') } catch (e) { console.warn('停止音频源失败:', e) } audioSource = null } // 取消所有进行中的请求 segmentRequests.clear() audioQueue = [] isPlayingQueue = false currentPlayingIndex = -1 isSpeaking.value = false isPaused.value = false isLoading.value = false // 恢复中断标志,为下一次播放准备 setTimeout(() => { isCancelled = false isInterrupted = false }, 100) } // 主speak方法 const speak = async (text) => { console.log('开始新的语音播报') // 先停止当前播放 if (isSpeaking.value || audioQueue.length > 0) { console.log('检测到正在播放,先停止') stopAudio() // 等待一小段时间确保资源清理完成 await new Promise(resolve => setTimeout(resolve, 200)) } text = extractSpeechText(text) console.log('开始语音播报:', text) // 重置状态 isCancelled = false isInterrupted = false currentText.value = text isLoading.value = true isSpeaking.value = true progress.value = 0 audioQueue = [] segmentRequests.clear() 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. 初始预加载前N个片段(N = minQueueSize) console.log(`初始预加载前${Math.min(minQueueSize, segments.length)}个片段`) await preloadAudioSegments(0) if (isCancelled || isInterrupted) { console.log('播放被取消或中断') return } // 2. 开始播放 console.log('开始播放音频队列') playFromQueue() // 3. 等待播放完成或中断 while (lastPlayedIndex < segments.length - 1 && !isCancelled && !isInterrupted) { await new Promise(resolve => setTimeout(resolve, 500)) // 更新加载状态 if (isLoading.value && audioQueue.length > 0) { isLoading.value = false } } if (isCancelled || isInterrupted) { console.log('播放被取消或中断') } else { console.log('音频播放完成') isSpeaking.value = false isPaused.value = false isLoading.value = false progress.value = 100 } } catch (error) { console.error('语音播报失败:', error) } finally { // 最终清理 if (isCancelled || isInterrupted) { console.log('播放被取消或中断,进行清理') cleanup() } } } // 暂停播放 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 } }