Files
qingdao-employment-service/hook/useAudioSpeak.js
2025-12-26 18:15:57 +08:00

657 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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
}
}