2025-12-22 17:46:40 +08:00
|
|
|
|
// 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 // 当前正在播放的片段索引
|
2025-12-22 18:03:10 +08:00
|
|
|
|
let isInterrupted = false // 是否被中断
|
2025-12-22 17:46:40 +08:00
|
|
|
|
|
|
|
|
|
|
// 按标点分割文本
|
|
|
|
|
|
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 = () => {
|
2025-12-22 18:03:10 +08:00
|
|
|
|
if (!audioContext || audioContext.state === 'closed') {
|
2025-12-22 17:46:40 +08:00
|
|
|
|
audioContext = new (window.AudioContext || window.webkitAudioContext)()
|
|
|
|
|
|
console.log('音频上下文已初始化')
|
|
|
|
|
|
}
|
|
|
|
|
|
return audioContext
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 解码并播放音频Blob
|
|
|
|
|
|
const decodeAndPlayBlob = async (audioBlob, segmentIndex) => {
|
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
|
if (isCancelled || !audioContext) {
|
2025-12-22 18:03:10 +08:00
|
|
|
|
console.log('播放已取消或音频上下文不存在,跳过播放')
|
2025-12-22 17:46:40 +08:00
|
|
|
|
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)
|
|
|
|
|
|
|
2025-12-22 18:03:10 +08:00
|
|
|
|
// 如果在此期间被取消,直接返回
|
|
|
|
|
|
if (isCancelled) {
|
|
|
|
|
|
console.log('播放过程中被取消')
|
|
|
|
|
|
resolve()
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-22 17:46:40 +08:00
|
|
|
|
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}个片段`)
|
2025-12-22 18:03:10 +08:00
|
|
|
|
|
|
|
|
|
|
// 如果音频上下文被暂停,先恢复
|
|
|
|
|
|
if (audioContext.state === 'suspended') {
|
|
|
|
|
|
await audioContext.resume()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-22 17:46:40 +08:00
|
|
|
|
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 {
|
2025-12-22 18:03:10 +08:00
|
|
|
|
while (audioQueue.length > 0 && !isCancelled && !isInterrupted) {
|
2025-12-22 17:46:40 +08:00
|
|
|
|
const audioItem = audioQueue[0]
|
|
|
|
|
|
currentSegmentIndex.value = audioItem.segmentIndex
|
|
|
|
|
|
|
|
|
|
|
|
// 播放第一个音频
|
|
|
|
|
|
console.log(`准备播放第${audioItem.segmentIndex + 1}个片段: "${audioItem.text}"`)
|
|
|
|
|
|
await decodeAndPlayBlob(audioItem.blob, audioItem.segmentIndex)
|
|
|
|
|
|
|
2025-12-22 18:03:10 +08:00
|
|
|
|
if (isCancelled || isInterrupted) {
|
|
|
|
|
|
console.log('播放被中断,退出播放队列')
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-22 17:46:40 +08:00
|
|
|
|
// 播放完成后移除
|
|
|
|
|
|
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) => {
|
2025-12-22 18:03:10 +08:00
|
|
|
|
// 如果被取消或中断,不添加到队列
|
|
|
|
|
|
if (isCancelled || isInterrupted) {
|
|
|
|
|
|
console.log('播放已中断,不添加到队列')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-22 17:46:40 +08:00
|
|
|
|
// 按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;
|
2025-12-25 16:14:11 +08:00
|
|
|
|
|
2025-12-22 17:46:40 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-12-25 16:14:11 +08:00
|
|
|
|
|
2025-12-22 17:46:40 +08:00
|
|
|
|
const guideText = firstJobStartIndex > 0 ?
|
|
|
|
|
|
markdown.slice(0, firstJobStartIndex).trim() : '';
|
|
|
|
|
|
const endingText = lastJobEndIndex < markdown.length ?
|
|
|
|
|
|
markdown.slice(lastJobEndIndex).trim() : '';
|
2025-12-25 16:14:11 +08:00
|
|
|
|
|
2025-12-22 17:46:40 +08:00
|
|
|
|
const jobTexts = jobs.map((job, index) => {
|
2025-12-25 16:21:36 +08:00
|
|
|
|
// 处理薪资格式
|
2025-12-25 16:14:11 +08:00
|
|
|
|
let salaryText = job.salary;
|
2025-12-25 16:21:36 +08:00
|
|
|
|
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元/月" 格式,保持原样
|
2025-12-25 16:14:11 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return `第 ${index + 1} 个岗位,岗位名称是:${job.jobTitle},公司是:${job.companyName},薪资:${salaryText},地点:${job.location},学历要求:${job.education},经验要求:${job.experience}。`;
|
2025-12-22 17:46:40 +08:00
|
|
|
|
});
|
2025-12-25 16:14:11 +08:00
|
|
|
|
|
2025-12-22 17:46:40 +08:00
|
|
|
|
const finalTextParts = [];
|
|
|
|
|
|
if (guideText) finalTextParts.push(guideText);
|
|
|
|
|
|
finalTextParts.push(...jobTexts);
|
|
|
|
|
|
if (endingText) finalTextParts.push(endingText);
|
2025-12-25 16:14:11 +08:00
|
|
|
|
|
2025-12-22 17:46:40 +08:00
|
|
|
|
return finalTextParts.join('\n');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 清理资源
|
|
|
|
|
|
const cleanup = () => {
|
2025-12-22 18:03:10 +08:00
|
|
|
|
console.log('开始清理资源')
|
2025-12-22 17:46:40 +08:00
|
|
|
|
isCancelled = true
|
2025-12-22 18:03:10 +08:00
|
|
|
|
isInterrupted = true
|
2025-12-22 17:46:40 +08:00
|
|
|
|
|
|
|
|
|
|
if (audioSource) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
audioSource.stop()
|
2025-12-22 18:03:10 +08:00
|
|
|
|
console.log('音频源已停止')
|
2025-12-22 17:46:40 +08:00
|
|
|
|
} catch (e) {
|
2025-12-22 18:03:10 +08:00
|
|
|
|
console.warn('停止音频源失败:', e)
|
2025-12-22 17:46:40 +08:00
|
|
|
|
}
|
|
|
|
|
|
audioSource = null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (audioContext && audioContext.state !== 'closed') {
|
|
|
|
|
|
audioContext.close()
|
2025-12-22 18:03:10 +08:00
|
|
|
|
console.log('音频上下文已关闭')
|
2025-12-22 17:46:40 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2025-12-22 18:03:10 +08:00
|
|
|
|
console.log('资源清理完成')
|
2025-12-22 17:46:40 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 停止播放
|
|
|
|
|
|
const stopAudio = () => {
|
2025-12-22 18:03:10 +08:00
|
|
|
|
console.log('停止音频播放')
|
2025-12-22 17:46:40 +08:00
|
|
|
|
isCancelled = true
|
2025-12-22 18:03:10 +08:00
|
|
|
|
isInterrupted = true
|
|
|
|
|
|
|
2025-12-22 17:46:40 +08:00
|
|
|
|
if (audioSource) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
audioSource.stop()
|
2025-12-22 18:03:10 +08:00
|
|
|
|
console.log('音频源已停止')
|
2025-12-22 17:46:40 +08:00
|
|
|
|
} catch (e) {
|
2025-12-22 18:03:10 +08:00
|
|
|
|
console.warn('停止音频源失败:', e)
|
2025-12-22 17:46:40 +08:00
|
|
|
|
}
|
|
|
|
|
|
audioSource = null
|
|
|
|
|
|
}
|
2025-12-22 18:03:10 +08:00
|
|
|
|
|
|
|
|
|
|
audioQueue = []
|
2025-12-22 17:46:40 +08:00
|
|
|
|
isPlayingQueue = false
|
|
|
|
|
|
currentPlayingIndex = -1
|
|
|
|
|
|
isSpeaking.value = false
|
|
|
|
|
|
isPaused.value = false
|
|
|
|
|
|
isLoading.value = false
|
2025-12-22 18:03:10 +08:00
|
|
|
|
|
|
|
|
|
|
// 恢复中断标志,为下一次播放准备
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
isCancelled = false
|
|
|
|
|
|
isInterrupted = false
|
|
|
|
|
|
}, 100)
|
2025-12-22 17:46:40 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 主speak方法
|
|
|
|
|
|
const speak = async (text) => {
|
2025-12-22 18:03:10 +08:00
|
|
|
|
console.log('开始新的语音播报')
|
2025-12-22 17:46:40 +08:00
|
|
|
|
|
2025-12-22 18:03:10 +08:00
|
|
|
|
// 先停止当前播放
|
|
|
|
|
|
if (isSpeaking.value || audioQueue.length > 0) {
|
|
|
|
|
|
console.log('检测到正在播放,先停止')
|
2025-12-22 17:46:40 +08:00
|
|
|
|
stopAudio()
|
2025-12-22 18:03:10 +08:00
|
|
|
|
// 等待一小段时间确保资源清理完成
|
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 200))
|
2025-12-22 17:46:40 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-22 18:03:10 +08:00
|
|
|
|
text = extractSpeechText(text)
|
|
|
|
|
|
console.log('开始语音播报:', text)
|
|
|
|
|
|
|
2025-12-22 17:46:40 +08:00
|
|
|
|
// 重置状态
|
|
|
|
|
|
isCancelled = false
|
2025-12-22 18:03:10 +08:00
|
|
|
|
isInterrupted = false
|
2025-12-22 17:46:40 +08:00
|
|
|
|
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++) {
|
2025-12-22 18:03:10 +08:00
|
|
|
|
if (isCancelled || isInterrupted) {
|
|
|
|
|
|
console.log('播放被取消或中断,停止请求')
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
2025-12-22 17:46:40 +08:00
|
|
|
|
|
|
|
|
|
|
console.log(`串行请求第${i + 1}/${segments.length}个片段`)
|
|
|
|
|
|
|
|
|
|
|
|
// 更新进度(请求进度)
|
|
|
|
|
|
progress.value = Math.floor(((i + 1) / segments.length) * 50)
|
|
|
|
|
|
|
|
|
|
|
|
// 请求音频片段
|
|
|
|
|
|
const audioItem = await fetchAudioSegment(segments[i], i)
|
|
|
|
|
|
|
2025-12-22 18:03:10 +08:00
|
|
|
|
if (isCancelled || isInterrupted) {
|
|
|
|
|
|
console.log('播放被取消或中断,停止添加队列')
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-22 17:46:40 +08:00
|
|
|
|
// 添加到播放队列
|
|
|
|
|
|
addToQueue({
|
|
|
|
|
|
blob: audioItem.blob,
|
|
|
|
|
|
segmentIndex: i,
|
|
|
|
|
|
text: segments[i]
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 如果是第一个片段,取消loading状态
|
|
|
|
|
|
if (i === 0 && isLoading.value) {
|
|
|
|
|
|
console.log('第一个音频片段已就绪,开始播放')
|
|
|
|
|
|
isLoading.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-22 18:03:10 +08:00
|
|
|
|
if (isCancelled || isInterrupted) {
|
|
|
|
|
|
console.log('播放被取消或中断,退出播放')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-22 17:46:40 +08:00
|
|
|
|
// 2. 所有请求完成
|
|
|
|
|
|
console.log('所有音频片段请求完成')
|
|
|
|
|
|
allRequestsCompleted = true
|
|
|
|
|
|
|
|
|
|
|
|
// 3. 立即检查是否可以合并剩余片段
|
|
|
|
|
|
tryMergeRemainingSegments()
|
|
|
|
|
|
|
|
|
|
|
|
// 4. 等待所有音频播放完成
|
2025-12-22 18:03:10 +08:00
|
|
|
|
while (audioQueue.length > 0 && !isCancelled && !isInterrupted) {
|
2025-12-22 17:46:40 +08:00
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 100))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
console.log('音频播放完成')
|
|
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('语音播报失败:', error)
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
// 最终清理
|
2025-12-22 18:03:10 +08:00
|
|
|
|
if (isCancelled || isInterrupted) {
|
|
|
|
|
|
console.log('播放被取消或中断,进行清理')
|
2025-12-22 17:46:40 +08:00
|
|
|
|
cleanup()
|
|
|
|
|
|
} else {
|
2025-12-22 18:03:10 +08:00
|
|
|
|
console.log('播放正常完成')
|
2025-12-22 17:46:40 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|