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

682 lines
19 KiB
JavaScript
Raw Normal View History

2025-12-22 17:46:40 +08:00
// useAudioSpeak.js
import { ref } from 'vue'
2025-12-26 16:44:11 +08:00
import globalConfig from '@/config.js';
import useUserStore from '@/stores/useUserStore';
2025-12-22 17:46:40 +08:00
/**
* TTS语音合成Hook (队列播放版本)
2025-12-22 17:46:40 +08:00
* @param {Object} config - TTS配置
* @param {string} config.apiUrl - 语音合成API地址
* @param {number} config.maxSegmentLength - 最大分段长度
2025-12-26 18:15:57 +08:00
* @param {number} config.minQueueSize - 最小队列缓存数量
* @param {number} config.maxRetry - 最大重试次数
2025-12-22 17:46:40 +08:00
* @returns {Object} TTS相关方法和状态
*/
export const useAudioSpeak = (config = {}) => {
const {
2025-12-26 18:15:57 +08:00
apiUrl = `${globalConfig.baseUrl}/app/speech/tts`,
maxSegmentLength = 30,
minQueueSize = 3, // 最小队列缓存数量
maxRetry = 2, // 最大重试次数
onStatusChange = () => {} // 状态变化回调
2025-12-22 17:46:40 +08:00
} = 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 // 当前正在播放的片段索引
2025-12-22 17:46:40 +08:00
let audioContext = null
let audioSource = null
let currentAudioUrl = '' // 当前播放的音频URL
// 文本提取工具函数
function extractSpeechText(markdown) {
if (!markdown || markdown.indexOf('job-json') === -1) {
return markdown;
2025-12-22 17:46:40 +08:00
}
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);
}
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() : '';
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');
2025-12-22 17:46:40 +08:00
}
/**
* 智能文本切割
* 作用合并短句减少请求次数切分长句避免超时
*/
const _smartSplit = (text) => {
if (!text || typeof text !== 'string') return [];
const cleanText = text.replace(/\s+/g, ' ').trim();
2025-12-29 10:28:49 +08:00
if (!cleanText) return [];
2025-12-29 10:28:49 +08:00
const segments = [];
// 1. 按完整标点分割成独立的句子(包括中英文标点)
// 正则解释:匹配非标点字符 + 标点符号(或者匹配到结尾)
const sentenceRegex = /([^。?!;,、\n\r\.\?!;,]+[。?!;,、\n\r\.\?!;,]+|.+$)/g;
let currentIndex = 0;
let match;
const rawSentences = [];
while ((match = sentenceRegex.exec(cleanText)) !== null) {
const sentence = match[0].trim();
if (sentence) {
rawSentences.push(sentence);
}
currentIndex = match.index + match[0].length;
}
// 处理最后剩余的部分
if (currentIndex < cleanText.length) {
const remaining = cleanText.substring(currentIndex).trim();
if (remaining) {
rawSentences.push(remaining);
}
}
// 如果正则没有匹配到,整个文本作为一句话
if (rawSentences.length === 0) {
rawSentences.push(cleanText);
}
// 2. 处理每个句子
for (const sentence of rawSentences) {
if (sentence.length <= maxSegmentLength) {
// 句子长度正常,直接作为一个片段
segments.push(sentence);
} else {
2025-12-29 10:28:49 +08:00
// 句子超长,需要分割
console.log('检测到超长句子,需要分割:', sentence);
let currentPos = 0;
const sentenceLength = sentence.length;
while (currentPos < sentenceLength) {
// 优先在标点处分割
let splitPos = -1;
const searchStart = currentPos;
const searchEnd = Math.min(currentPos + maxSegmentLength, sentenceLength);
// 在搜索范围内找标点
for (let i = searchEnd - 1; i > searchStart; i--) {
if (/[。?!;,、\n\r\.\?!;,]/u.test(sentence[i])) {
splitPos = i + 1; // 包含标点
break;
}
}
// 如果没找到标点,在最大限制处分割
if (splitPos === -1) {
splitPos = searchEnd;
}
// 确保至少分割出一个字符
if (splitPos <= currentPos) {
splitPos = currentPos + 1;
}
const segment = sentence.substring(currentPos, splitPos).trim();
if (segment) {
segments.push(segment);
}
currentPos = splitPos;
}
}
}
2025-12-22 17:46:40 +08:00
2025-12-29 10:28:49 +08:00
// 3. 特殊情况:合并以冒号开头的短片段到上一句
const finalSegments = [];
2025-12-29 10:28:49 +08:00
for (let i = 0; i < segments.length; i++) {
const currentSegment = segments[i];
// 检查是否以冒号开头且很短(可能是被错误分割的部分)
if (i > 0 &&
(currentSegment.startsWith('') || currentSegment.startsWith(':')) &&
currentSegment.length < 15 &&
!currentSegment.endsWith('。') &&
!currentSegment.endsWith('!') &&
!currentSegment.endsWith('?')) {
// 尝试合并到上一句
const previousSegment = finalSegments[finalSegments.length - 1];
if (previousSegment && (previousSegment.length + currentSegment.length) <= maxSegmentLength) {
finalSegments[finalSegments.length - 1] = previousSegment + currentSegment;
} else {
finalSegments.push(currentSegment);
2025-12-22 17:46:40 +08:00
}
2025-12-29 10:28:49 +08:00
} else {
finalSegments.push(currentSegment);
2025-12-22 17:46:40 +08:00
}
2025-12-29 10:28:49 +08:00
}
2025-12-22 17:46:40 +08:00
2025-12-29 10:28:49 +08:00
// 清理:移除空白和空字符串
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);
2025-12-22 17:46:40 +08:00
}
/**
* 网络请求包装 (带重试机制)
*/
const _fetchAudioWithRetry = async (text, retries = 0) => {
2025-12-22 17:46:40 +08:00
try {
console.log(`📶正在请求音频: "${text}"`);
2025-12-26 16:44:11 +08:00
let Authorization = '';
2025-12-26 16:44:11 +08:00
if (useUserStore().token) {
Authorization = `${useUserStore().token}`;
2025-12-26 16:44:11 +08:00
}
2025-12-26 18:15:57 +08:00
const response = await fetch(`${apiUrl}?text=${encodeURIComponent(text)}`, {
method: 'GET',
2025-12-22 17:46:40 +08:00
headers: {
'Content-Type': 'application/json',
2025-12-26 18:15:57 +08:00
'Authorization': encodeURIComponent(Authorization)
}
});
2025-12-22 17:46:40 +08:00
if (!response.ok) {
throw new Error(`HTTP错误! 状态码: ${response.status}`);
2025-12-22 17:46:40 +08:00
}
const audioBlob = await response.blob();
2025-12-22 17:46:40 +08:00
if (!audioBlob || audioBlob.size < 100) {
throw new Error('音频数据太小或无效');
2025-12-22 17:46:40 +08:00
}
console.log(`音频获取成功,大小: ${audioBlob.size} 字节`);
2025-12-22 17:46:40 +08:00
// 创建Blob URL
return URL.createObjectURL(audioBlob);
2025-12-22 17:46:40 +08:00
} 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();
}
2025-12-22 17:46:40 +08:00
} catch (error) {
console.error('缓冲维护失败:', error);
// 即使失败,也要继续处理下一个文本,防止死锁
2025-12-26 18:15:57 +08:00
} finally {
isFetching = false;
// 递归调用:继续检查是否需要填充缓冲
_maintainBuffer();
2025-12-22 17:46:40 +08:00
}
}
/**
* 初始化音频上下文
*/
const initAudioContext = () => {
if (!audioContext || audioContext.state === 'closed') {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
console.log('音频上下文已初始化');
2025-12-26 18:15:57 +08:00
}
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();
2025-12-26 18:15:57 +08:00
}
return;
2025-12-26 18:15:57 +08:00
}
// 2. 正常播放
const item = audioQueue.splice(itemIndex, 1)[0];
2025-12-26 18:15:57 +08:00
// 释放上一段的内存
if (currentAudioUrl) {
URL.revokeObjectURL(currentAudioUrl);
currentAudioUrl = '';
2025-12-26 18:15:57 +08:00
}
currentPlayingIndex = item.index;
currentSegmentIndex.value = item.index;
currentAudioUrl = item.blobUrl;
// 更新进度
if (totalSegments.value > 0) {
progress.value = Math.floor(((item.index + 1) / totalSegments.value) * 100);
2025-12-22 17:46:40 +08:00
}
_updateState({
isLoading: false,
msg: `播放中: ${item.text.substring(0, 15)}...`
});
// 播放音频
_playAudio(item.blobUrl, item.text, item.index);
// 消费了一个,通知生产者补充库存
_maintainBuffer();
2025-12-22 17:46:40 +08:00
}
/**
* 播放音频
*/
const _playAudio = async (blobUrl, text, index) => {
2025-12-22 17:46:40 +08:00
return new Promise((resolve, reject) => {
if (!isPlaying || isPaused.value) {
resolve();
return;
2025-12-22 17:46:40 +08:00
}
initAudioContext();
2025-12-22 17:46:40 +08:00
const fileReader = new FileReader();
2025-12-22 17:46:40 +08:00
fileReader.onload = async (e) => {
try {
const arrayBuffer = e.target.result;
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
2025-12-22 17:46:40 +08:00
// 如果在此期间被暂停或停止,直接返回
if (!isPlaying || isPaused.value) {
resolve();
return;
2025-12-22 18:03:10 +08:00
}
audioSource = audioContext.createBufferSource();
audioSource.buffer = audioBuffer;
audioSource.connect(audioContext.destination);
2025-12-22 17:46:40 +08:00
audioSource.onended = () => {
console.log(`${index + 1}个片段播放完成`);
audioSource = null;
2025-12-26 18:15:57 +08:00
// 播放下一段
_playNext();
resolve();
};
2025-12-22 17:46:40 +08:00
audioSource.onerror = (error) => {
console.error('音频播放错误:', error);
audioSource = null;
// 跳过错误片段,尝试下一段
setTimeout(() => {
_playNext();
}, 100);
reject(error);
};
2025-12-22 17:46:40 +08:00
console.log(`▶️开始播放第${index + 1}个片段: "${text}`);
2025-12-22 18:03:10 +08:00
// 如果音频上下文被暂停,先恢复
if (audioContext.state === 'suspended') {
await audioContext.resume();
2025-12-22 18:03:10 +08:00
}
audioSource.start(0);
2025-12-22 17:46:40 +08:00
} catch (error) {
console.error('解码或播放音频失败:', error);
audioSource = null;
// 跳过错误片段,尝试下一段
setTimeout(() => {
_playNext();
}, 100);
reject(error);
2025-12-22 17:46:40 +08:00
}
};
2025-12-22 17:46:40 +08:00
fileReader.onerror = (error) => {
console.error('读取音频文件失败:', error);
audioSource = null;
// 跳过错误片段,尝试下一段
setTimeout(() => {
_playNext();
}, 100);
reject(error);
};
2025-12-22 17:46:40 +08:00
// 先获取Blob再读取
fetch(blobUrl)
.then(response => response.blob())
.then(blob => {
fileReader.readAsArrayBuffer(blob);
})
.catch(error => {
console.error('获取Blob失败:', error);
reject(error);
});
});
2025-12-22 17:46:40 +08:00
}
/**
* 核心入口开始播放长文本
*/
const speak = async (text) => {
console.log('开始新的语音播报');
2025-12-22 17:46:40 +08:00
cleanup();
// 等待一小段时间确保资源清理完成
await new Promise(resolve => setTimeout(resolve, 200));
2025-12-26 18:15:57 +08:00
text = extractSpeechText(text);
console.log('开始语音播报:', text);
2025-12-26 18:15:57 +08:00
// 重置状态
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 = '';
2025-12-22 17:46:40 +08:00
}
// 1. 智能切割文本
const segments = _smartSplit(text);
console.log('文本分段结果:', segments);
2025-12-22 17:46:40 +08:00
if (segments.length === 0) {
console.warn('没有有效的文本可以播报');
_updateState({ isPlaying: false, msg: '没有有效的文本' });
isSpeaking.value = false;
isLoading.value = false;
return;
2025-12-22 18:03:10 +08:00
}
totalSegments.value = segments.length;
2025-12-26 18:15:57 +08:00
// 2. 填充文本队列
segments.forEach((segment, index) => {
textQueue.push({ text: segment, index });
});
2025-12-26 18:15:57 +08:00
_updateState({ isLoading: true, msg: '初始化播放...' });
2025-12-22 17:46:40 +08:00
// 3. 启动"缓冲流水线"
_maintainBuffer();
2025-12-22 17:46:40 +08:00
// 4. 开始播放
setTimeout(() => {
_playNext();
}, 100);
2025-12-22 17:46:40 +08:00
}
/**
* 暂停播放
*/
const pause = () => {
if (isPlaying && !isPaused.value) {
isPaused.value = true;
if (audioContext) {
audioContext.suspend().then(() => {
_updateState({ isPaused: true, msg: '已暂停' });
});
} else {
_updateState({ isPaused: true, msg: '已暂停' });
2025-12-22 17:46:40 +08:00
}
}
}
/**
* 恢复播放
*/
const resume = () => {
2025-12-22 17:46:40 +08:00
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();
2025-12-22 17:46:40 +08:00
}
}
}
/**
* 停止播放
*/
2025-12-22 17:46:40 +08:00
const stopAudio = () => {
console.log('停止音频播放');
isPlaying = false;
isPaused.value = false;
isFetching = false;
isSpeaking.value = false;
isLoading.value = false;
2025-12-22 18:03:10 +08:00
// 停止音频源
2025-12-22 17:46:40 +08:00
if (audioSource) {
try {
audioSource.stop();
console.log('音频源已停止');
2025-12-22 17:46:40 +08:00
} catch (e) {
console.warn('停止音频源失败:', e);
2025-12-22 17:46:40 +08:00
}
audioSource = null;
2025-12-22 17:46:40 +08:00
}
2025-12-22 18:03:10 +08:00
// 暂停音频上下文
if (audioContext && audioContext.state !== 'closed') {
audioContext.suspend();
2025-12-22 17:46:40 +08:00
}
// 清理所有 Blob 内存
if (currentAudioUrl) {
URL.revokeObjectURL(currentAudioUrl);
currentAudioUrl = '';
}
2025-12-22 18:03:10 +08:00
// 清理音频队列中的Blob URL
audioQueue.forEach(item => {
if (item.blobUrl) {
URL.revokeObjectURL(item.blobUrl);
}
});
2025-12-22 17:46:40 +08:00
// 清空队列
textQueue = [];
audioQueue = [];
2025-12-22 17:46:40 +08:00
currentPlayingIndex = -1;
progress.value = 0;
2025-12-22 17:46:40 +08:00
_updateState({ isPlaying: false, msg: '已停止' });
2025-12-22 17:46:40 +08:00
}
/**
* 清理资源
*/
const cleanup = () => {
console.log('开始清理资源');
stopAudio();
if (audioContext && audioContext.state !== 'closed') {
audioContext.close();
console.log('音频上下文已关闭');
2025-12-22 17:46:40 +08:00
}
audioContext = null;
console.log('资源清理完成');
2025-12-22 17:46:40 +08:00
}
return {
// 状态
isSpeaking,
isPaused,
isLoading,
currentText,
currentSegmentIndex,
totalSegments,
progress,
// 方法
speak,
pause,
resume,
cancelAudio: stopAudio,
2025-12-22 17:46:40 +08:00
cleanup
}
}