Files
qingdao-employment-service/hook/useAudioSpeak.js

664 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'
/**
* TTS语音合成Hook
* @param {Object} config - TTS配置
* @param {string} config.apiUrl - 语音合成API地址
* @param {number} config.maxSegmentLength - 最大分段长度
* @returns {Object} TTS相关方法和状态
*/
export const useAudioSpeak = (config = {}) => {
const {
apiUrl = 'http://39.98.44.136:19527/synthesize',
maxSegmentLength = 30
} = 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, isMerged?}, ...]
let isPlayingQueue = false
let isCancelled = false
let segments = []
let allRequestsCompleted = false
let pendingMergeSegments = [] // 存储所有片段的原始数据 [{arrayBuffer, segmentIndex}]
let firstSegmentHeader = null
let lastPlayedIndex = -1
let currentPlayingIndex = -1 // 当前正在播放的片段索引
// 按标点分割文本
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())
}
// 检测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 {
console.log(`📶正在请求第${index + 1}段音频: "${text}"`)
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
text: text,
speed: 1.0,
volume: 1.0,
pitch: 1.0,
voice_type: 1
})
})
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} 字节`)
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
}
} catch (error) {
console.error(`获取第${index + 1}段音频失败:`, error)
throw error
}
}
// 初始化音频上下文
const initAudioContext = () => {
if (!audioContext) {
audioContext = new (window.AudioContext || window.webkitAudioContext)()
console.log('音频上下文已初始化')
}
return audioContext
}
// 解码并播放音频Blob
const decodeAndPlayBlob = async (audioBlob, segmentIndex) => {
return new Promise((resolve, reject) => {
if (isCancelled || !audioContext) {
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)
audioSource = audioContext.createBufferSource()
audioSource.buffer = audioBuffer
audioSource.connect(audioContext.destination)
audioSource.onended = () => {
console.log(`${segmentIndex + 1}个片段播放完成`)
audioSource = null
lastPlayedIndex = segmentIndex
currentPlayingIndex = -1
resolve()
}
audioSource.onerror = (error) => {
console.error('音频播放错误:', error)
currentPlayingIndex = -1
reject(error)
}
console.log(`▶️开始播放第${segmentIndex + 1}个片段`)
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 mergeRemainingSegments = (segmentsToMerge) => {
if (segmentsToMerge.length === 0 || !firstSegmentHeader) {
console.log('没有待合并的片段或缺少头部信息')
return null
}
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 playFromQueue = async () => {
if (isPlayingQueue || audioQueue.length === 0) {
return
}
isPlayingQueue = true
try {
while (audioQueue.length > 0 && !isCancelled) {
const audioItem = audioQueue[0]
currentSegmentIndex.value = audioItem.segmentIndex
// 播放第一个音频
console.log(`准备播放第${audioItem.segmentIndex + 1}个片段: "${audioItem.text}"`)
await decodeAndPlayBlob(audioItem.blob, audioItem.segmentIndex)
// 播放完成后移除
audioQueue.shift()
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 (audioQueue.length === 0 && lastPlayedIndex === totalSegments.value - 1) {
console.log('所有音频片段播放完成')
progress.value = 100
}
} catch (error) {
console.error('播放队列出错:', error)
} finally {
isPlayingQueue = false
}
}
// 向队列添加音频
const addToQueue = (audioItem) => {
// 按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}已添加到队列,队列长度: ${audioQueue.length}`)
// 如果队列中有音频且当前没有在播放,开始播放
if (!isPlayingQueue && audioQueue.length === 1) {
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) {
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');
}
// 清理资源
const cleanup = () => {
isCancelled = true
if (audioSource) {
try {
audioSource.stop()
} catch (e) {
// 忽略错误
}
audioSource = null
}
if (audioContext && audioContext.state !== 'closed') {
audioContext.close()
}
audioContext = null
audioQueue = []
segments = []
isPlayingQueue = false
allRequestsCompleted = false
pendingMergeSegments = []
firstSegmentHeader = null
lastPlayedIndex = -1
currentPlayingIndex = -1
isSpeaking.value = false
isPaused.value = false
isLoading.value = false
progress.value = 0
}
// 停止播放
const stopAudio = () => {
isCancelled = true
if (audioSource) {
try {
audioSource.stop()
} catch (e) {
// 忽略错误
}
audioSource = null
}
isPlayingQueue = false
currentPlayingIndex = -1
isSpeaking.value = false
isPaused.value = false
isLoading.value = false
}
// 主speak方法
const speak = async (text) => {
text = extractSpeechText(text)
console.log('开始语音播报:', text)
// 如果正在播放,先停止
if (isSpeaking.value) {
stopAudio()
}
// 重置状态
isCancelled = false
currentText.value = text
isLoading.value = true
isSpeaking.value = true
progress.value = 0
audioQueue = []
allRequestsCompleted = false
pendingMergeSegments = []
firstSegmentHeader = null
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. 串行请求所有音频片段
for (let i = 0; i < segments.length; i++) {
if (isCancelled) break
console.log(`串行请求第${i + 1}/${segments.length}个片段`)
// 更新进度(请求进度)
progress.value = Math.floor(((i + 1) / segments.length) * 50)
// 请求音频片段
const audioItem = await fetchAudioSegment(segments[i], i)
// 添加到播放队列
addToQueue({
blob: audioItem.blob,
segmentIndex: i,
text: segments[i]
})
// 如果是第一个片段取消loading状态
if (i === 0 && isLoading.value) {
console.log('第一个音频片段已就绪,开始播放')
isLoading.value = false
}
}
// 2. 所有请求完成
console.log('所有音频片段请求完成')
allRequestsCompleted = true
// 3. 立即检查是否可以合并剩余片段
tryMergeRemainingSegments()
// 4. 等待所有音频播放完成
while (audioQueue.length > 0 && !isCancelled) {
await new Promise(resolve => setTimeout(resolve, 100))
}
console.log('音频播放完成')
} catch (error) {
console.error('语音播报失败:', error)
} finally {
// 最终清理
if (isCancelled) {
cleanup()
} else {
isSpeaking.value = false
isPaused.value = false
isLoading.value = false
progress.value = 100
}
}
}
// 暂停播放
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
}
}