diff --git a/hook/useAudioSpeak-copy.js b/hook/useAudioSpeak-copy.js new file mode 100644 index 0000000..1307c0c --- /dev/null +++ b/hook/useAudioSpeak-copy.js @@ -0,0 +1,619 @@ +// 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.substring(0, 20)}..."`); + + 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.substring(0, 20)}..."`); + + // 如果音频上下文被暂停,先恢复 + 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('开始新的语音播报'); + + // 先停止当前播放 + if (isPlaying) { + console.log('检测到正在播放,先停止'); + stopAudio(); + // 等待一小段时间确保资源清理完成 + 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 + } +} \ No newline at end of file diff --git a/hook/useAudioSpeak.js b/hook/useAudioSpeak.js index 81ef790..46beabb 100644 --- a/hook/useAudioSpeak.js +++ b/hook/useAudioSpeak.js @@ -4,18 +4,21 @@ import globalConfig from '@/config.js'; import useUserStore from '@/stores/useUserStore'; /** - * TTS语音合成Hook + * 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 // 最小队列缓存数量 + minQueueSize = 3, // 最小队列缓存数量 + maxRetry = 2, // 最大重试次数 + onStatusChange = () => {} // 状态变化回调 } = config // 状态 @@ -29,368 +32,18 @@ export const useAudioSpeak = (config = {}) => { 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 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() - } - } - + let currentAudioUrl = '' // 当前播放的音频URL + // 文本提取工具函数 function extractSpeechText(markdown) { if (!markdown || markdown.indexOf('job-json') === -1) { @@ -451,190 +104,498 @@ export const useAudioSpeak = (config = {}) => { return finalTextParts.join('\n'); } - // 清理资源 - const cleanup = () => { - console.log('开始清理资源') - isCancelled = true - isInterrupted = true + /** + * 智能文本切割 + * 作用:合并短句减少请求次数,切分长句避免超时 + */ + const _smartSplit = (text) => { + if (!text || typeof text !== 'string') return []; - if (audioSource) { - try { - audioSource.stop() - console.log('音频源已停止') - } catch (e) { - console.warn('停止音频源失败:', e) - } - audioSource = null - } + const cleanText = text.replace(/\s+/g, ' ').trim(); - 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 rawChunks = cleanText.split(/([。?!;\n\r]|……)/).filter((t) => t.trim()); + const mergedChunks = []; + let temp = ''; - // 停止播放 - 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 + 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 (isCancelled || isInterrupted) { - console.log('播放被取消或中断') + } + if (temp) mergedChunks.push(temp); + + // 如果还有超过 maxSegmentLength 的,强制分割 + const finalSegments = []; + mergedChunks.forEach(segment => { + if (segment.length <= maxSegmentLength) { + finalSegments.push(segment); } else { - console.log('音频播放完成') - isSpeaking.value = false - isPaused.value = false - isLoading.value = false - progress.value = 100 + // 按字数强制分割 + 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) + console.error('缓冲维护失败:', error); + // 即使失败,也要继续处理下一个文本,防止死锁 } finally { - // 最终清理 - if (isCancelled || isInterrupted) { - console.log('播放被取消或中断,进行清理') - cleanup() + 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 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('播放已恢复') - }) + + 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 cancelAudio = () => { - console.log('取消音频播放') - stopAudio() - cleanup() + /** + * 停止播放 + */ + 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 { @@ -651,7 +612,7 @@ export const useAudioSpeak = (config = {}) => { speak, pause, resume, - cancelAudio, + cancelAudio: stopAudio, cleanup } } \ No newline at end of file diff --git a/hook/useRealtimeRecorderOnce.js b/hook/useRealtimeRecorderOnce.js index 3dbcd3d..b0497bd 100644 --- a/hook/useRealtimeRecorderOnce.js +++ b/hook/useRealtimeRecorderOnce.js @@ -95,43 +95,81 @@ export function useRealtimeRecorderOnce() { // --- 音量计算函数 --- const calculateVolumeFromFloat32 = (float32Array) => { + if (!float32Array || float32Array.length === 0) return 0; + let sum = 0; const length = float32Array.length; // 计算RMS (均方根) for (let i = 0; i < length; i++) { - sum += float32Array[i] * float32Array[i]; + // 绝对值方法更敏感 + const absValue = Math.abs(float32Array[i]); + sum += absValue * absValue; } + const rms = Math.sqrt(sum / length); - + + // 调试:打印原始RMS值 + // console.log('Float32 RMS:', rms); + // 转换为0-100的值 - // 通常对话语音的RMS在0.01-0.1之间,尖叫可达0.3 - let volume = Math.min(100, Math.floor(rms * 300)); + // 使用对数刻度,使小音量变化更明显 + let volume = 0; + + if (rms > 0) { + // 使用对数转换:-60dB到0dB映射到0-100 + const db = 20 * Math.log10(rms); + // 静音阈值约-60dB + if (db > -60) { + volume = Math.min(100, Math.max(0, (db + 60) / 0.6)); + } + } - // 设置最小阈值,避免静音时完全为0 - if (volume < 5) volume = 0; + // 如果没有计算到值,使用旧方法作为fallback + if (volume === 0 && rms > 0) { + volume = Math.min(100, Math.floor(rms * 500)); + } - return volume; + // 确保最小值为0 + return Math.max(0, volume); } const calculateVolumeFromInt16 = (int16Array) => { + if (!int16Array || int16Array.length === 0) return 0; + let sum = 0; const length = int16Array.length; - + // 计算RMS for (let i = 0; i < length; i++) { const normalized = int16Array[i] / 32768; // 归一化到[-1, 1] - sum += normalized * normalized; + const absValue = Math.abs(normalized); + sum += absValue * absValue; } + const rms = Math.sqrt(sum / length); - + + // 调试:打印原始RMS值 + // console.log('Int16 RMS:', rms); + // 转换为0-100的值 - let volume = Math.min(100, Math.floor(rms * 300)); - - // 设置最小阈值 - if (volume < 5) volume = 0; - - return volume; + // 使用对数刻度 + let volume = 0; + + if (rms > 0) { + const db = 20 * Math.log10(rms); + if (db > -60) { + volume = Math.min(100, Math.max(0, (db + 60) / 0.6)); + } + } + + // 如果没有计算到值,使用旧方法作为fallback + if (volume === 0 && rms > 0) { + volume = Math.min(100, Math.floor(rms * 500)); + } + + // 确保最小值为0 + return Math.max(0, volume); } /** @@ -377,29 +415,67 @@ export function useRealtimeRecorderOnce() { return; } - // 生成波形数据,基于当前音量 - const baseValue = volumeLevel.value / 100; + // 获取当前音量值 + const currentVolume = volumeLevel.value; + + // 调试:打印音量值 + // console.log('Current Volume:', currentVolume); + + // 生成适合 WaveDisplay 的数据 const data = []; - - // 生成31个数据点 + const center = 15; // 中心索引 + const timeFactor = Date.now() / 150; // 更快的动画 + + // 根据音量动态调整波形强度 + const volumeFactor = currentVolume / 100; + + // 添加基础噪声,使波形在安静时也有轻微活动 + const baseNoise = Math.random() * 0.1; + for (let i = 0; i < 31; i++) { - // 使用正弦波生成波形效果,中间高两边低 - const position = i / 30; - const centerDistance = Math.abs(position - 0.5); - const waveValue = Math.sin(Date.now() / 200 + i * 0.3) * 0.4 + 0.5; - - // 音量因子确保最小显示高度 - const volumeFactor = baseValue * 0.7 + 0.3; - - // 综合计算最终值 - let finalValue = waveValue * (1 - centerDistance) * volumeFactor; - finalValue = Math.max(0.1, Math.min(1, finalValue)); - - data.push(finalValue); + // 距离中心的位置 + const distanceFromCenter = Math.abs(i - center) / center; + + // 基础波形模式 + const basePattern = 1 - Math.pow(distanceFromCenter, 1.2); + + // 动态效果 + const dynamicEffect = Math.sin(timeFactor + i * 0.3) * 0.3; + + // 计算基础值 + let value; + + if (volumeFactor > 0.1) { + // 有音量时:音量因子占主导 + value = volumeFactor * 0.8 * basePattern + + volumeFactor * 0.4 + + dynamicEffect * volumeFactor * 0.5; + } else { + // 安静时:使用动态效果和基础噪声 + value = basePattern * 0.2 + + dynamicEffect * 0.1 + + baseNoise; + } + + // 确保值在有效范围内 + value = Math.max(0.15, Math.min(1, value)); + + // 随机微调 + const randomVariance = volumeFactor > 0.1 ? 0.15 : 0.05; + value += (Math.random() - 0.5) * randomVariance; + value = Math.max(0.15, Math.min(1, value)); + + data.push(value); } - + audioDataForDisplay.value = data; - }, 50); // 更快的刷新率,更流畅 + + // 调试:检查生成的数据范围 + // const min = Math.min(...data); + // const max = Math.max(...data); + // console.log(`Data range: ${min.toFixed(3)} - ${max.toFixed(3)}`); + + }, 50); } /** diff --git a/pages/chat/components/ai-paging.vue b/pages/chat/components/ai-paging.vue index 2e9bbe4..f08608f 100644 --- a/pages/chat/components/ai-paging.vue +++ b/pages/chat/components/ai-paging.vue @@ -742,15 +742,15 @@ function readMarkdown(value, index) { speak(value); return; } + if (isPaused.value) { - resume(); + resume(); } else { - // console.log(value, speechIndex.value, index, isPaused.value) - speak(value); + pause(); } } function stopMarkdown(value, index) { - pause(value); + pause() speechIndex.value = index; } function refreshMarkdown(index) {