import { ref, onUnmounted, onMounted } from 'vue' // 如果是 uni-app 环境,保留这些导入;如果是纯 Web Vue3,可以移除 import { onHide, onUnload } from '@dcloudio/uni-app' import config from '@/config' /** * Piper TTS 播放钩子 (WebSocket MSE 流式版 - 含 cancelAudio) * 依赖: 后端必须去除 MP3 ID3 标签 (-map_metadata -1) */ export function useTTSPlayer() { // 状态管理 const isSpeaking = ref(false) const isPaused = ref(false) const isLoading = ref(false) // 核心对象 let audio = null let mediaSource = null let sourceBuffer = null let ws = null // 缓冲队列管理 let bufferQueue = [] let isAppending = false let isStreamEnded = false // 初始化 Audio 监听器 (只运行一次) const initAudioElement = () => { if (!audio && typeof window !== 'undefined') { audio = new Audio() // 错误监听 audio.addEventListener('error', (e) => { // 如果是手动停止导致的 error (src 被置空),忽略 if (!audio.src) return console.error('Audio Player Error:', e) resetState() }) // 播放结束监听 audio.addEventListener('ended', () => { resetState() }) } } /** * 核心朗读方法 (WebSocket) * @param {string} text - 要朗读的文本 */ const speak = async (text) => { if (!text) return // 1. 提取文本 const processedText = extractSpeechText(text) if (!processedText) return // 2. 彻底清理旧状态 cancelAudio() initAudioElement() isLoading.value = true isSpeaking.value = true isPaused.value = false isStreamEnded = false // 3. 检查环境 if (!window.MediaSource || !window.WebSocket) { console.error('当前环境不支持 MediaSource 或 WebSocket') resetState() return } try { // 4. 初始化 MSE mediaSource = new MediaSource() // 绑定 MSE 到 Audio audio.src = URL.createObjectURL(mediaSource) // 监听 MSE 打开事件 mediaSource.addEventListener('sourceopen', () => { // 防止多次触发 if (mediaSource.sourceBuffers.length > 0) return startWebSocketStream(processedText) }) // 尝试播放 (处理浏览器自动播放策略) const playPromise = audio.play() if (playPromise !== undefined) { playPromise.catch(e => { console.warn('自动播放被拦截 (需用户交互):', e) // 保持 isSpeaking 为 true,UI 显示播放按钮,用户点击后调用 resume() 即可 }) } } catch (err) { console.error('TTS Initialization Failed:', err) cancelAudio() } } // 启动 WebSocket 流程 const startWebSocketStream = (text) => { const mime = 'audio/mpeg' // 4.1 创建 SourceBuffer try { sourceBuffer = mediaSource.addSourceBuffer(mime) sourceBuffer.addEventListener('updateend', () => { isAppending = false processQueue() }) } catch (e) { console.error('SourceBuffer Create Failed:', e) return } // 4.2 计算 WebSocket 地址 let baseUrl = config.speechSynthesis2 || '' baseUrl = baseUrl.replace(/\/$/, '') const wsUrl = baseUrl.replace(/^http/, 'ws') + '/ws/synthesize' // 4.3 建立连接 ws = new WebSocket(wsUrl) ws.binaryType = 'arraybuffer' // 关键 ws.onopen = () => { // console.log('WS Open') ws.send(JSON.stringify({ text: text, speaker_id: 0, length_scale: 1.0, noise_scale: 0.667 })) isLoading.value = false } ws.onmessage = (event) => { if (event.data instanceof ArrayBuffer) { bufferQueue.push(event.data) processQueue() } } ws.onerror = (e) => { console.error('WS Error:', e) cancelAudio() } ws.onclose = () => { // console.log('WS Closed') isStreamEnded = true // 检查是否需要结束 MSE 流 checkEndOfStream() } } // 处理缓冲队列 const processQueue = () => { if (!sourceBuffer || sourceBuffer.updating || bufferQueue.length === 0) { // 如果队列空了,且流已结束,尝试结束 MSE if (bufferQueue.length === 0 && isStreamEnded && !sourceBuffer.updating) { checkEndOfStream() } return } isAppending = true const chunk = bufferQueue.shift() try { sourceBuffer.appendBuffer(chunk) } catch (e) { // console.error('AppendBuffer Error:', e) isAppending = false } } // 结束 MSE 流 const checkEndOfStream = () => { if (mediaSource && mediaSource.readyState === 'open' && bufferQueue.length === 0 && !sourceBuffer ?.updating) { try { mediaSource.endOfStream() } catch (e) {} } } const pause = () => { if (audio && !audio.paused) { audio.pause() isPaused.value = true isSpeaking.value = false } } const resume = () => { if (audio && audio.paused) { audio.play() isPaused.value = false isSpeaking.value = true } } // === 新增/核心方法:取消并停止 === const cancelAudio = () => { // 1. 断开 WebSocket (停止数据接收) if (ws) { // 移除监听器防止报错 ws.onclose = null ws.onerror = null ws.onmessage = null ws.close() ws = null } // 2. 停止音频播放 if (audio) { audio.pause() // 释放 Blob URL 内存 if (audio.src) { URL.revokeObjectURL(audio.src) audio.removeAttribute('src') } audio.currentTime = 0 } // 3. 清理 MSE 对象 if (mediaSource) { try { if (mediaSource.readyState === 'open') { mediaSource.endOfStream() } } catch (e) {} mediaSource = null } sourceBuffer = null bufferQueue = [] isAppending = false isStreamEnded = false // 4. 重置 UI 状态 resetState() } // 只是重置 UI 变量的辅助函数 const resetState = () => { isSpeaking.value = false isPaused.value = false isLoading.value = false } // 别名 stop -> cancelAudio (保持兼容性) const stop = cancelAudio // === 生命周期 === onMounted(() => { initAudioElement() }) onUnmounted(() => { cancelAudio() audio = null }) if (typeof onHide === 'function') onHide(cancelAudio) if (typeof onUnload === 'function') onUnload(cancelAudio) return { speak, pause, resume, stop, cancelAudio, // 新增导出 isSpeaking, isPaused, isLoading } } /** * 提取文本逻辑 */ 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'); }