// 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 - 最小队列缓存数量 * @param {number} config.maxRetry - 最大重试次数 * @returns {Object} TTS相关方法和状态 */ export const useAudioSpeak = (config = {}) => { const { apiUrl = `${globalConfig.baseUrl}/app/speech/tts`, maxSegmentLength = 30, minQueueSize = 3, // 最小队列缓存数量 maxRetry = 2, // 最大重试次数 onStatusChange = () => {} // 状态变化回调 } = 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 textQueue = [] // 等待转换的文本队列 [{text, index}] let audioQueue = [] // 已经转换好的音频队列 [{blobUrl, text, index}] // 控制标志 let isFetching = false // 是否正在请求音频 let isPlaying = false // 是否正在播放(逻辑状态) let currentPlayingIndex = -1 // 当前正在播放的片段索引 let audioContext = null let audioSource = null let currentAudioUrl = '' // 当前播放的音频URL // 文本提取工具函数 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 _smartSplit = (text) => { if (!text || typeof text !== 'string') return []; const cleanText = text.replace(/\s+/g, ' ').trim(); // 先按标点粗分 const rawChunks = cleanText.split(/([。?!;\n\r]|……)/).filter((t) => t.trim()); const mergedChunks = []; let temp = ''; for (let i = 0; i < rawChunks.length; i++) { const part = rawChunks[i]; // 如果是标点,追加到上一句 if (/^[。?!;\n\r……]$/.test(part)) { temp += part; } else { // 如果当前积累的句子太长(超过50字),先推入队列 if (temp.length > 50) { mergedChunks.push(temp); temp = part; } else if (temp.length + part.length < 15) { // 如果当前积累的太短(少于15字),则合并下一句 temp += part; } else { // 正常长度,推入 if (temp) mergedChunks.push(temp); temp = part; } } } if (temp) mergedChunks.push(temp); // 如果还有超过 maxSegmentLength 的,强制分割 const finalSegments = []; mergedChunks.forEach(segment => { if (segment.length <= maxSegmentLength) { finalSegments.push(segment); } else { // 按字数强制分割 for (let i = 0; i < segment.length; i += maxSegmentLength) { finalSegments.push(segment.substring(i, Math.min(i + maxSegmentLength, segment.length))); } } }); return finalSegments.filter(seg => seg && seg.trim()); } /** * 统一状态通知 */ const _updateState = (state = {}) => { // 合并当前状态供 UI 使用 const payload = { isPlaying: isPlaying, isPaused: isPaused.value, isLoading: state.isLoading || false, msg: state.msg || '', currentSegmentIndex: currentSegmentIndex.value, totalSegments: totalSegments.value, progress: progress.value }; // 更新响应式状态 isLoading.value = state.isLoading || false; // 调用回调 onStatusChange(payload); } /** * 网络请求包装 (带重试机制) */ const _fetchAudioWithRetry = async (text, retries = 0) => { try { console.log(`📶正在请求音频: "${text}"`); let Authorization = ''; if (useUserStore().token) { Authorization = `${useUserStore().token}`; } const response = await fetch(`${apiUrl}?text=${encodeURIComponent(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(`音频获取成功,大小: ${audioBlob.size} 字节`); // 创建Blob URL return URL.createObjectURL(audioBlob); } catch (e) { if (retries < maxRetry) { console.warn(`重试 ${retries + 1} 次,文本: ${text.substring(0, 10)}...`); return await _fetchAudioWithRetry(text, retries + 1); } throw e; } } /** * 缓冲维护器 (生产者) * 始终保持 audioQueue 里有足够的音频 */ const _maintainBuffer = async () => { // 如果正在请求,或 文本队列没了,或 音频缓冲已达标,则停止请求 if (isFetching || textQueue.length === 0) { return; } // 缓冲策略:如果音频队列里的数量少于 minQueueSize,就继续下载 if (audioQueue.length >= minQueueSize) { return; } isFetching = true; const textItem = textQueue.shift(); // 从文本队列取出 try { const blobUrl = await _fetchAudioWithRetry(textItem.text); audioQueue.push({ blobUrl, text: textItem.text, index: textItem.index }); console.log(`音频已添加到队列,当前队列长度: ${audioQueue.length}`); // 如果当前因为没音频卡住了(Loading状态),立即尝试播放下一段 if (!audioSource && !isPaused.value && isPlaying) { _playNext(); } } catch (error) { console.error('缓冲维护失败:', error); // 即使失败,也要继续处理下一个文本,防止死锁 } finally { isFetching = false; // 递归调用:继续检查是否需要填充缓冲 _maintainBuffer(); } } /** * 初始化音频上下文 */ const initAudioContext = () => { if (!audioContext || audioContext.state === 'closed') { audioContext = new (window.AudioContext || window.webkitAudioContext)(); console.log('音频上下文已初始化'); } return audioContext; } /** * 播放控制器 (消费者) */ const _playNext = () => { if (!isPlaying || isPaused.value) { return; } // 尝试从缓冲队列获取下一个应该播放的片段 const nextIndex = currentPlayingIndex + 1; const itemIndex = audioQueue.findIndex(item => item.index === nextIndex); // 1. 缓冲耗尽:进入"加载中"等待状态 if (itemIndex === -1) { if (textQueue.length === 0 && !isFetching) { // 彻底播完了 console.log('所有音频播放完成'); stopAudio(); _updateState({ isPlaying: false, msg: '播放结束' }); } else { // 还有文本没转完,说明网速慢了,正在缓冲 _updateState({ isLoading: true, msg: '缓冲中...' }); // 确保生产线在运行 _maintainBuffer(); } return; } // 2. 正常播放 const item = audioQueue.splice(itemIndex, 1)[0]; // 释放上一段的内存 if (currentAudioUrl) { URL.revokeObjectURL(currentAudioUrl); currentAudioUrl = ''; } currentPlayingIndex = item.index; currentSegmentIndex.value = item.index; currentAudioUrl = item.blobUrl; // 更新进度 if (totalSegments.value > 0) { progress.value = Math.floor(((item.index + 1) / totalSegments.value) * 100); } _updateState({ isLoading: false, msg: `播放中: ${item.text.substring(0, 15)}...` }); // 播放音频 _playAudio(item.blobUrl, item.text, item.index); // 消费了一个,通知生产者补充库存 _maintainBuffer(); } /** * 播放音频 */ const _playAudio = async (blobUrl, text, index) => { return new Promise((resolve, reject) => { if (!isPlaying || isPaused.value) { resolve(); return; } initAudioContext(); const fileReader = new FileReader(); fileReader.onload = async (e) => { try { const arrayBuffer = e.target.result; const audioBuffer = await audioContext.decodeAudioData(arrayBuffer); // 如果在此期间被暂停或停止,直接返回 if (!isPlaying || isPaused.value) { resolve(); return; } audioSource = audioContext.createBufferSource(); audioSource.buffer = audioBuffer; audioSource.connect(audioContext.destination); audioSource.onended = () => { console.log(`第${index + 1}个片段播放完成`); audioSource = null; // 播放下一段 _playNext(); resolve(); }; audioSource.onerror = (error) => { console.error('音频播放错误:', error); audioSource = null; // 跳过错误片段,尝试下一段 setTimeout(() => { _playNext(); }, 100); reject(error); }; console.log(`▶️开始播放第${index + 1}个片段: "${text}`); // 如果音频上下文被暂停,先恢复 if (audioContext.state === 'suspended') { await audioContext.resume(); } audioSource.start(0); } catch (error) { console.error('解码或播放音频失败:', error); audioSource = null; // 跳过错误片段,尝试下一段 setTimeout(() => { _playNext(); }, 100); reject(error); } }; fileReader.onerror = (error) => { console.error('读取音频文件失败:', error); audioSource = null; // 跳过错误片段,尝试下一段 setTimeout(() => { _playNext(); }, 100); reject(error); }; // 先获取Blob,再读取 fetch(blobUrl) .then(response => response.blob()) .then(blob => { fileReader.readAsArrayBuffer(blob); }) .catch(error => { console.error('获取Blob失败:', error); reject(error); }); }); } /** * 核心入口:开始播放长文本 */ const speak = async (text) => { console.log('开始新的语音播报'); cleanup(); // 等待一小段时间确保资源清理完成 await new Promise(resolve => setTimeout(resolve, 200)); text = extractSpeechText(text); console.log('开始语音播报:', text); // 重置状态 isPlaying = true; isPaused.value = false; isLoading.value = true; isSpeaking.value = true; currentText.value = text; progress.value = 0; currentPlayingIndex = -1; currentSegmentIndex.value = 0; // 清空队列 textQueue = []; audioQueue = []; // 清理之前的Blob URL if (currentAudioUrl) { URL.revokeObjectURL(currentAudioUrl); currentAudioUrl = ''; } // 1. 智能切割文本 const segments = _smartSplit(text); console.log('文本分段结果:', segments); if (segments.length === 0) { console.warn('没有有效的文本可以播报'); _updateState({ isPlaying: false, msg: '没有有效的文本' }); isSpeaking.value = false; isLoading.value = false; return; } totalSegments.value = segments.length; // 2. 填充文本队列 segments.forEach((segment, index) => { textQueue.push({ text: segment, index }); }); _updateState({ isLoading: true, msg: '初始化播放...' }); // 3. 启动"缓冲流水线" _maintainBuffer(); // 4. 开始播放 setTimeout(() => { _playNext(); }, 100); } /** * 暂停播放 */ const pause = () => { if (isPlaying && !isPaused.value) { isPaused.value = true; if (audioContext) { audioContext.suspend().then(() => { _updateState({ isPaused: true, msg: '已暂停' }); }); } else { _updateState({ isPaused: true, msg: '已暂停' }); } } } /** * 恢复播放 */ const resume = () => { if (isPlaying && isPaused.value) { isPaused.value = false; // 恢复播放。如果当前有音频源则直接resume,否则调_playNext if (audioContext && audioContext.state === 'suspended') { audioContext.resume().then(() => { _updateState({ isPaused: false, msg: '继续播放' }); }); } else if (audioSource) { _updateState({ isPaused: false, msg: '继续播放' }); } else { _playNext(); } } } /** * 停止播放 */ const stopAudio = () => { console.log('停止音频播放'); isPlaying = false; isPaused.value = false; isFetching = false; isSpeaking.value = false; isLoading.value = false; // 停止音频源 if (audioSource) { try { audioSource.stop(); console.log('音频源已停止'); } catch (e) { console.warn('停止音频源失败:', e); } audioSource = null; } // 暂停音频上下文 if (audioContext && audioContext.state !== 'closed') { audioContext.suspend(); } // 清理所有 Blob 内存 if (currentAudioUrl) { URL.revokeObjectURL(currentAudioUrl); currentAudioUrl = ''; } // 清理音频队列中的Blob URL audioQueue.forEach(item => { if (item.blobUrl) { URL.revokeObjectURL(item.blobUrl); } }); // 清空队列 textQueue = []; audioQueue = []; currentPlayingIndex = -1; progress.value = 0; _updateState({ isPlaying: false, msg: '已停止' }); } /** * 清理资源 */ const cleanup = () => { console.log('开始清理资源'); stopAudio(); if (audioContext && audioContext.state !== 'closed') { audioContext.close(); console.log('音频上下文已关闭'); } audioContext = null; console.log('资源清理完成'); } return { // 状态 isSpeaking, isPaused, isLoading, currentText, currentSegmentIndex, totalSegments, progress, // 方法 speak, pause, resume, cancelAudio: stopAudio, cleanup } }