657 lines
19 KiB
JavaScript
657 lines
19 KiB
JavaScript
// 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
|
||
}
|
||
} |