From 8b98e476d8f9703fcdcbac2f49adbc7b49dd8fe9 Mon Sep 17 00:00:00 2001 From: xiebing Date: Fri, 26 Dec 2025 18:15:57 +0800 Subject: [PATCH] =?UTF-8?q?feat=20:=20tts=E6=AD=A3=E5=B8=B8=E6=92=AD?= =?UTF-8?q?=E6=94=BE=E6=9A=82=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hook/useAudioSpeak.js | 422 ++++++++++++++++-------------------------- 1 file changed, 161 insertions(+), 261 deletions(-) diff --git a/hook/useAudioSpeak.js b/hook/useAudioSpeak.js index b795ef1..81ef790 100644 --- a/hook/useAudioSpeak.js +++ b/hook/useAudioSpeak.js @@ -8,13 +8,14 @@ import useUserStore from '@/stores/useUserStore'; * @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 = 'http://39.98.44.136:19527/synthesize', - apiUrl = `${globalConfig.baseUrl}/app/synthesize`, - maxSegmentLength = 30 + apiUrl = `${globalConfig.baseUrl}/app/speech/tts`, + maxSegmentLength = 30, + minQueueSize = 3 // 最小队列缓存数量 } = config // 状态 @@ -31,16 +32,14 @@ export const useAudioSpeak = (config = {}) => { // 音频相关 let audioContext = null let audioSource = null - let audioQueue = [] // 播放队列 [{blob, segmentIndex, text, isMerged?}, ...] + let audioQueue = [] // 播放队列 [{blob, segmentIndex, text, isProcessing?}, ...] let isPlayingQueue = false let isCancelled = false let segments = [] - let allRequestsCompleted = false - let pendingMergeSegments = [] // 存储所有片段的原始数据 [{arrayBuffer, segmentIndex}] - let firstSegmentHeader = null let lastPlayedIndex = -1 let currentPlayingIndex = -1 // 当前正在播放的片段索引 let isInterrupted = false // 是否被中断 + let segmentRequests = new Map() // 存储正在进行中的请求 {index: promise} // 按标点分割文本 const splitTextByPunctuation = (text) => { @@ -91,26 +90,6 @@ export const useAudioSpeak = (config = {}) => { 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 { @@ -120,19 +99,14 @@ export const useAudioSpeak = (config = {}) => { if (useUserStore().token) { Authorization = `${useUserStore().token}` } - const response = await fetch(apiUrl, { - method: 'POST', + + const response = await fetch(`${apiUrl}?text=${text}`, { + method: 'get', headers: { 'Content-Type': 'application/json', - 'Authorization':encodeURIComponent(Authorization) + 'Authorization': encodeURIComponent(Authorization) }, - body: JSON.stringify({ - text: text, - speed: 1.0, - volume: 1.0, - pitch: 1.0, - voice_type: 1 - }) + }) if (!response.ok) { @@ -147,33 +121,75 @@ export const useAudioSpeak = (config = {}) => { 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 + 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') { @@ -219,6 +235,10 @@ export const useAudioSpeak = (config = {}) => { audioSource = null lastPlayedIndex = segmentIndex currentPlayingIndex = -1 + + // 片段播放完成后,检查是否需要预加载更多 + checkAndPreloadNextSegments() + resolve() } @@ -254,58 +274,22 @@ export const useAudioSpeak = (config = {}) => { }) } - // 合并剩余音频片段 - const mergeRemainingSegments = (segmentsToMerge) => { - if (segmentsToMerge.length === 0 || !firstSegmentHeader) { - console.log('没有待合并的片段或缺少头部信息') - return null + // 检查并预加载下一个片段 + const checkAndPreloadNextSegments = () => { + if (isCancelled || isInterrupted) { + return } - 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 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) } } @@ -319,10 +303,21 @@ export const useAudioSpeak = (config = {}) => { try { while (audioQueue.length > 0 && !isCancelled && !isInterrupted) { - const audioItem = audioQueue[0] + // 找到下一个应该播放的片段(按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) @@ -331,8 +326,8 @@ export const useAudioSpeak = (config = {}) => { break } - // 播放完成后移除 - audioQueue.shift() + // 播放完成后从队列中移除 + audioQueue.splice(nextItemIndex, 1) console.log(`片段${audioItem.segmentIndex + 1}播放完成,队列剩余: ${audioQueue.length}`) // 更新进度 @@ -343,9 +338,11 @@ export const useAudioSpeak = (config = {}) => { } // 检查是否所有片段都播放完成 - if (audioQueue.length === 0 && lastPlayedIndex === totalSegments.value - 1) { + if (lastPlayedIndex === totalSegments.value - 1) { console.log('所有音频片段播放完成') progress.value = 100 + isSpeaking.value = false + isPaused.value = false } } catch (error) { @@ -363,6 +360,19 @@ export const useAudioSpeak = (config = {}) => { 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--) { @@ -373,99 +383,14 @@ export const useAudioSpeak = (config = {}) => { } audioQueue.splice(insertIndex, 0, audioItem) - console.log(`音频片段${audioItem.segmentIndex + 1}已添加到队列,队列长度: ${audioQueue.length}`) + console.log(`音频片段${audioItem.segmentIndex + 1}已添加到队列位置${insertIndex},队列长度: ${audioQueue.length}`) // 如果队列中有音频且当前没有在播放,开始播放 - if (!isPlayingQueue && audioQueue.length === 1) { + if (!isPlayingQueue && audioQueue.some(item => item.segmentIndex === 0)) { 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) { @@ -499,16 +424,16 @@ export const useAudioSpeak = (config = {}) => { 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); + 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}千每月`; } @@ -547,13 +472,13 @@ export const useAudioSpeak = (config = {}) => { console.log('音频上下文已关闭') } + // 取消所有进行中的请求 + segmentRequests.clear() + audioContext = null audioQueue = [] segments = [] isPlayingQueue = false - allRequestsCompleted = false - pendingMergeSegments = [] - firstSegmentHeader = null lastPlayedIndex = -1 currentPlayingIndex = -1 @@ -580,6 +505,9 @@ export const useAudioSpeak = (config = {}) => { audioSource = null } + // 取消所有进行中的请求 + segmentRequests.clear() + audioQueue = [] isPlayingQueue = false currentPlayingIndex = -1 @@ -617,9 +545,7 @@ export const useAudioSpeak = (config = {}) => { isSpeaking.value = true progress.value = 0 audioQueue = [] - allRequestsCompleted = false - pendingMergeSegments = [] - firstSegmentHeader = null + segmentRequests.clear() lastPlayedIndex = -1 currentPlayingIndex = -1 @@ -640,59 +566,39 @@ export const useAudioSpeak = (config = {}) => { // 初始化音频上下文 initAudioContext() - // 1. 串行请求所有音频片段 - for (let i = 0; i < segments.length; i++) { - if (isCancelled || isInterrupted) { - console.log('播放被取消或中断,停止请求') - break - } + // 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)) - console.log(`串行请求第${i + 1}/${segments.length}个片段`) - - // 更新进度(请求进度) - progress.value = Math.floor(((i + 1) / segments.length) * 50) - - // 请求音频片段 - const audioItem = await fetchAudioSegment(segments[i], i) - - if (isCancelled || isInterrupted) { - console.log('播放被取消或中断,停止添加队列') - break - } - - // 添加到播放队列 - addToQueue({ - blob: audioItem.blob, - segmentIndex: i, - text: segments[i] - }) - - // 如果是第一个片段,取消loading状态 - if (i === 0 && isLoading.value) { - console.log('第一个音频片段已就绪,开始播放') + // 更新加载状态 + if (isLoading.value && audioQueue.length > 0) { isLoading.value = false } } if (isCancelled || isInterrupted) { - console.log('播放被取消或中断,退出播放') - return + console.log('播放被取消或中断') + } else { + console.log('音频播放完成') + isSpeaking.value = false + isPaused.value = false + isLoading.value = false + progress.value = 100 } - // 2. 所有请求完成 - console.log('所有音频片段请求完成') - allRequestsCompleted = true - - // 3. 立即检查是否可以合并剩余片段 - tryMergeRemainingSegments() - - // 4. 等待所有音频播放完成 - while (audioQueue.length > 0 && !isCancelled && !isInterrupted) { - await new Promise(resolve => setTimeout(resolve, 100)) - } - - console.log('音频播放完成') - } catch (error) { console.error('语音播报失败:', error) } finally { @@ -700,12 +606,6 @@ export const useAudioSpeak = (config = {}) => { if (isCancelled || isInterrupted) { console.log('播放被取消或中断,进行清理') cleanup() - } else { - console.log('播放正常完成') - isSpeaking.value = false - isPaused.value = false - isLoading.value = false - progress.value = 100 } } }