Files
qingdao-employment-service/hook/useAudioSpeak.js
2025-12-29 10:28:49 +08:00

682 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 - 最小队列缓存数量
* @param {number} config.maxRetry - 最大重试次数
* @returns {Object} TTS相关方法和状态
*/
export const useAudioSpeak = (config = {}) => {
const {
apiUrl = `${globalConfig.baseUrl}/app/speech/tts`,
maxSegmentLength = 30,
minQueueSize = 3, // 最小队列缓存数量
maxRetry = 2, // 最大重试次数
onStatusChange = () => {} // 状态变化回调
} = 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 // 当前正在播放的片段索引
let audioContext = null
let audioSource = null
let currentAudioUrl = '' // 当前播放的音频URL
// 文本提取工具函数
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 _smartSplit = (text) => {
if (!text || typeof text !== 'string') return [];
const cleanText = text.replace(/\s+/g, ' ').trim();
if (!cleanText) return [];
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 {
// 句子超长,需要分割
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;
}
}
}
// 3. 特殊情况:合并以冒号开头的短片段到上一句
const finalSegments = [];
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);
}
} else {
finalSegments.push(currentSegment);
}
}
// 清理:移除空白和空字符串
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);
}
/**
* 网络请求包装 (带重试机制)
*/
const _fetchAudioWithRetry = async (text, retries = 0) => {
try {
console.log(`📶正在请求音频: "${text}"`);
let Authorization = '';
if (useUserStore().token) {
Authorization = `${useUserStore().token}`;
}
const response = await fetch(`${apiUrl}?text=${encodeURIComponent(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(`音频获取成功,大小: ${audioBlob.size} 字节`);
// 创建Blob URL
return URL.createObjectURL(audioBlob);
} 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();
}
} catch (error) {
console.error('缓冲维护失败:', error);
// 即使失败,也要继续处理下一个文本,防止死锁
} finally {
isFetching = false;
// 递归调用:继续检查是否需要填充缓冲
_maintainBuffer();
}
}
/**
* 初始化音频上下文
*/
const initAudioContext = () => {
if (!audioContext || audioContext.state === 'closed') {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
console.log('音频上下文已初始化');
}
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();
}
return;
}
// 2. 正常播放
const item = audioQueue.splice(itemIndex, 1)[0];
// 释放上一段的内存
if (currentAudioUrl) {
URL.revokeObjectURL(currentAudioUrl);
currentAudioUrl = '';
}
currentPlayingIndex = item.index;
currentSegmentIndex.value = item.index;
currentAudioUrl = item.blobUrl;
// 更新进度
if (totalSegments.value > 0) {
progress.value = Math.floor(((item.index + 1) / totalSegments.value) * 100);
}
_updateState({
isLoading: false,
msg: `播放中: ${item.text.substring(0, 15)}...`
});
// 播放音频
_playAudio(item.blobUrl, item.text, item.index);
// 消费了一个,通知生产者补充库存
_maintainBuffer();
}
/**
* 播放音频
*/
const _playAudio = async (blobUrl, text, index) => {
return new Promise((resolve, reject) => {
if (!isPlaying || isPaused.value) {
resolve();
return;
}
initAudioContext();
const fileReader = new FileReader();
fileReader.onload = async (e) => {
try {
const arrayBuffer = e.target.result;
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
// 如果在此期间被暂停或停止,直接返回
if (!isPlaying || isPaused.value) {
resolve();
return;
}
audioSource = audioContext.createBufferSource();
audioSource.buffer = audioBuffer;
audioSource.connect(audioContext.destination);
audioSource.onended = () => {
console.log(`${index + 1}个片段播放完成`);
audioSource = null;
// 播放下一段
_playNext();
resolve();
};
audioSource.onerror = (error) => {
console.error('音频播放错误:', error);
audioSource = null;
// 跳过错误片段,尝试下一段
setTimeout(() => {
_playNext();
}, 100);
reject(error);
};
console.log(`▶️开始播放第${index + 1}个片段: "${text}`);
// 如果音频上下文被暂停,先恢复
if (audioContext.state === 'suspended') {
await audioContext.resume();
}
audioSource.start(0);
} catch (error) {
console.error('解码或播放音频失败:', error);
audioSource = null;
// 跳过错误片段,尝试下一段
setTimeout(() => {
_playNext();
}, 100);
reject(error);
}
};
fileReader.onerror = (error) => {
console.error('读取音频文件失败:', error);
audioSource = null;
// 跳过错误片段,尝试下一段
setTimeout(() => {
_playNext();
}, 100);
reject(error);
};
// 先获取Blob再读取
fetch(blobUrl)
.then(response => response.blob())
.then(blob => {
fileReader.readAsArrayBuffer(blob);
})
.catch(error => {
console.error('获取Blob失败:', error);
reject(error);
});
});
}
/**
* 核心入口:开始播放长文本
*/
const speak = async (text) => {
console.log('开始新的语音播报');
cleanup();
// 等待一小段时间确保资源清理完成
await new Promise(resolve => setTimeout(resolve, 200));
text = extractSpeechText(text);
console.log('开始语音播报:', text);
// 重置状态
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 = '';
}
// 1. 智能切割文本
const segments = _smartSplit(text);
console.log('文本分段结果:', segments);
if (segments.length === 0) {
console.warn('没有有效的文本可以播报');
_updateState({ isPlaying: false, msg: '没有有效的文本' });
isSpeaking.value = false;
isLoading.value = false;
return;
}
totalSegments.value = segments.length;
// 2. 填充文本队列
segments.forEach((segment, index) => {
textQueue.push({ text: segment, index });
});
_updateState({ isLoading: true, msg: '初始化播放...' });
// 3. 启动"缓冲流水线"
_maintainBuffer();
// 4. 开始播放
setTimeout(() => {
_playNext();
}, 100);
}
/**
* 暂停播放
*/
const pause = () => {
if (isPlaying && !isPaused.value) {
isPaused.value = true;
if (audioContext) {
audioContext.suspend().then(() => {
_updateState({ isPaused: true, msg: '已暂停' });
});
} else {
_updateState({ isPaused: true, msg: '已暂停' });
}
}
}
/**
* 恢复播放
*/
const resume = () => {
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();
}
}
}
/**
* 停止播放
*/
const stopAudio = () => {
console.log('停止音频播放');
isPlaying = false;
isPaused.value = false;
isFetching = false;
isSpeaking.value = false;
isLoading.value = false;
// 停止音频源
if (audioSource) {
try {
audioSource.stop();
console.log('音频源已停止');
} catch (e) {
console.warn('停止音频源失败:', e);
}
audioSource = null;
}
// 暂停音频上下文
if (audioContext && audioContext.state !== 'closed') {
audioContext.suspend();
}
// 清理所有 Blob 内存
if (currentAudioUrl) {
URL.revokeObjectURL(currentAudioUrl);
currentAudioUrl = '';
}
// 清理音频队列中的Blob URL
audioQueue.forEach(item => {
if (item.blobUrl) {
URL.revokeObjectURL(item.blobUrl);
}
});
// 清空队列
textQueue = [];
audioQueue = [];
currentPlayingIndex = -1;
progress.value = 0;
_updateState({ isPlaying: false, msg: '已停止' });
}
/**
* 清理资源
*/
const cleanup = () => {
console.log('开始清理资源');
stopAudio();
if (audioContext && audioContext.state !== 'closed') {
audioContext.close();
console.log('音频上下文已关闭');
}
audioContext = null;
console.log('资源清理完成');
}
return {
// 状态
isSpeaking,
isPaused,
isLoading,
currentText,
currentSegmentIndex,
totalSegments,
progress,
// 方法
speak,
pause,
resume,
cancelAudio: stopAudio,
cleanup
}
}