Files
ks-app-employment-service/hook/useTTSPlayer.js
2026-01-24 17:57:25 +08:00

642 lines
25 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.

import {
ref,
onUnmounted,
onBeforeUnmount,
onMounted
} from 'vue'
import {
onHide,
onUnload
} from '@dcloudio/uni-app'
// 创建单例实例
let ttsInstance = null
// 创建音频播放器类
class TTSPlayer {
constructor(httpUrl) {
this.httpUrl = httpUrl
this.isSpeaking = ref(false)
this.isPaused = ref(false)
this.isComplete = ref(false)
// #ifdef H5
this.audioContext = null
this.htmlAudioElement = null
this.abortController = null
// #endif
// #ifdef MP-WEIXIN
this.backgroundAudioManager = null
this.innerAudioContext = null
// #endif
this.currentAudioBuffer = null
this.currentSource = null
this.playTimeOffset = 0
this.isProcessingRequest = false
}
// 初始化微信小程序音频上下文
initAudioManager() {
try {
console.log('📱 微信小程序:创建背景音频管理器')
this.backgroundAudioManager = uni.getBackgroundAudioManager()
// 设置默认配置
this.backgroundAudioManager.title = 'AI语音播报'
this.backgroundAudioManager.singer = 'KS AI'
this.backgroundAudioManager.coverImgUrl = '/static/icon/logo.png'
this.backgroundAudioManager.volume = 1.0
this.backgroundAudioManager.onPlay(() => {
console.log('🎵 微信小程序背景音频播放开始')
this.isSpeaking.value = true
this.isPaused.value = false
})
this.backgroundAudioManager.onPause(() => {
console.log('⏸️ 微信小程序背景音频播放暂停')
this.isPaused.value = true
})
this.backgroundAudioManager.onStop(() => {
console.log('⏹️ 微信小程序背景音频播放停止')
this.isSpeaking.value = false
this.isComplete.value = true
})
this.backgroundAudioManager.onEnded(() => {
console.log('🎵 微信小程序背景音频播放结束')
this.isSpeaking.value = false
this.isComplete.value = true
})
this.backgroundAudioManager.onError((res) => {
console.error('❌ 微信小程序背景音频播放错误:', res.errMsg, '错误码:', res.errCode)
this.isSpeaking.value = false
this.isComplete.value = false
})
this.backgroundAudioManager.onCanplay(() => {
console.log('🎵 微信小程序背景音频可以播放了')
})
console.log('✅ 微信小程序背景音频管理器初始化成功')
return true
} catch (e) {
console.error('❌ 微信小程序背景音频管理器初始化失败:', e)
// 降级使用InnerAudioContext
console.log('🔄 微信小程序背景音频不可用降级使用InnerAudioContext')
if (!this.innerAudioContext) {
this.innerAudioContext = uni.createInnerAudioContext()
this.innerAudioContext.autoplay = false
this.innerAudioContext.obeyMuteSwitch = false
this.innerAudioContext.onPlay(() => {
console.log('🎵 微信小程序InnerAudioContext播放开始')
this.isSpeaking.value = true
this.isPaused.value = false
})
this.innerAudioContext.onPause(() => {
console.log('⏸️ 微信小程序InnerAudioContext播放暂停')
this.isPaused.value = true
})
this.innerAudioContext.onStop(() => {
console.log('⏹️ 微信小程序InnerAudioContext播放停止')
this.isSpeaking.value = false
this.isComplete.value = true
})
this.innerAudioContext.onEnded(() => {
console.log('🎵 微信小程序InnerAudioContext播放结束')
this.isSpeaking.value = false
this.isComplete.value = true
})
this.innerAudioContext.onError((res) => {
console.error('❌ 微信小程序InnerAudioContext错误:', res.errMsg, '错误码:', res.errCode)
this.isSpeaking.value = false
this.isComplete.value = false
})
this.innerAudioContext.onCanplay(() => {
console.log('🎵 微信小程序InnerAudioContext可以播放了')
if (this.isSpeaking.value && !this.isPaused.value) {
this.innerAudioContext.play()
}
})
}
return false
}
}
// H5环境播放解码后的音频
playDecodedAudio(decoded) {
// #ifdef H5
if (!this.audioContext) return;
// 创建音频源
this.currentSource = this.audioContext.createBufferSource()
this.currentSource.buffer = decoded
this.currentSource.connect(this.audioContext.destination)
// 监听播放结束
this.currentSource.onended = () => {
console.log('🎵 Audio playback completed');
this.isSpeaking.value = false
this.isComplete.value = true
}
// 开始播放
this.currentSource.start()
this.isSpeaking.value = true
this.isPaused.value = false
this.isComplete.value = false
console.log('🎵 Audio playback started');
// #endif
}
// 降级处理:创建一个简单的音频缓冲区
createFallbackAudio(arrayBuffer) {
// #ifdef H5
console.log('🔄 使用降级方案创建音频');
// 创建一个简单的音频缓冲区,生成提示音
const sampleRate = 44100
const duration = 1 // 1秒
const frameCount = sampleRate * duration
const audioBuffer = this.audioContext.createBuffer(1, frameCount, sampleRate)
const channelData = audioBuffer.getChannelData(0)
// 生成一个简单的提示音(正弦波)
for (let i = 0; i < frameCount; i++) {
const t = i / sampleRate
channelData[i] = Math.sin(2 * Math.PI * 440 * t) * 0.1 // 440Hz正弦波音量0.1
}
this.playDecodedAudio(audioBuffer)
// #endif
}
// 暂停播放
pause() {
console.log('⏸️ TTS pause called');
// #ifdef MP-WEIXIN
// 优先使用背景音频管理器
if (this.backgroundAudioManager) {
try {
this.backgroundAudioManager.pause()
console.log('⏸️ 微信小程序背景音频暂停');
return
} catch (e) {
console.error('❌ 微信小程序背景音频暂停失败:', e);
}
}
// 降级使用InnerAudioContext
if (this.innerAudioContext && this.isSpeaking.value && !this.isPaused.value) {
try {
this.innerAudioContext.pause()
console.log('⏸️ 微信小程序InnerAudioContext暂停');
return
} catch (e) {
console.error('❌ 微信小程序InnerAudioContext暂停失败:', e);
}
}
// #endif
// #ifdef H5
if (this.audioContext && !this.isSpeaking.value || this.isPaused.value) {
console.warn('⚠️ Cannot pause TTS playback');
return;
}
if (this.audioContext.state === 'running') {
this.audioContext.suspend()
this.isPaused.value = true
// 保存当前播放位置
this.playTimeOffset = this.audioContext.currentTime
console.log('✅ H5 Audio paused successfully');
}
// #endif
}
// 恢复播放
resume() {
console.log('▶️ TTS resume called');
// #ifdef MP-WEIXIN
// 优先使用背景音频管理器
if (this.backgroundAudioManager) {
try {
this.backgroundAudioManager.play()
console.log('▶️ 微信小程序背景音频恢复播放');
return
} catch (e) {
console.error('❌ 微信小程序背景音频恢复失败:', e);
}
}
// 降级使用InnerAudioContext
if (this.innerAudioContext && this.isSpeaking.value && this.isPaused.value) {
try {
this.innerAudioContext.play()
console.log('▶️ 微信小程序InnerAudioContext恢复播放');
return
} catch (e) {
console.error('❌ 微信小程序InnerAudioContext恢复失败:', e);
}
}
// #endif
// #ifdef H5
if (this.audioContext && !this.isSpeaking.value || !this.isPaused.value) {
console.warn('⚠️ Cannot resume TTS playback');
return;
}
if (this.audioContext.state === 'suspended') {
this.audioContext.resume()
this.isPaused.value = false
console.log('✅ H5 Audio resumed successfully');
}
// #endif
}
// 停止播放
stop() {
console.log('⏹️ TTS stop called');
// #ifdef MP-WEIXIN
// 优先使用背景音频管理器
if (this.backgroundAudioManager) {
try {
this.backgroundAudioManager.stop()
console.log('✅ 微信小程序背景音频停止');
} catch (e) {
console.error('❌ 微信小程序背景音频停止失败:', e);
}
}
// 降级使用InnerAudioContext
if (this.innerAudioContext) {
try {
this.innerAudioContext.stop()
console.log('✅ 微信小程序InnerAudioContext停止');
this.innerAudioContext.destroy()
this.innerAudioContext = null
} catch (e) {
console.error('❌ 微信小程序InnerAudioContext停止错误:', e);
}
}
// #endif
// #ifdef H5
// 取消正在进行的fetch请求
if (this.abortController) {
try {
this.abortController.abort();
this.abortController = null;
console.log('✅ H5 fetch request aborted');
} catch (e) {
console.error('❌ Error aborting H5 fetch request:', e);
}
}
if (this.currentSource) {
try {
this.currentSource.stop()
this.currentSource.disconnect()
} catch (e) {
console.error('❌ Error stopping H5 audio source:', e);
}
this.currentSource = null
}
// 停止并清理HTML5 Audio元素
if (this.htmlAudioElement) {
try {
this.htmlAudioElement.pause();
this.htmlAudioElement.src = '';
this.htmlAudioElement = null;
console.log('✅ H5 HTML5 Audio element stopped and cleaned up');
} catch (e) {
console.error('❌ Error stopping H5 HTML5 Audio element:', e);
}
}
if (this.audioContext && this.audioContext.state === 'running') {
try {
this.audioContext.suspend()
} catch (e) {
console.error('❌ Error suspending H5 audio context:', e);
}
}
// #endif
this.isSpeaking.value = false
this.isPaused.value = false
this.isComplete.value = false
this.currentAudioBuffer = null
this.playTimeOffset = 0
this.isProcessingRequest = false
console.log('✅ TTS playback stopped');
}
// 取消音频播放
cancelAudio() {
this.stop()
}
// 合成并播放语音
async speak(text) {
// 停止当前播放和处理中的请求
this.stop()
try {
// 标记开始处理请求
this.isProcessingRequest = true
// 提取要合成的文本
const speechText = extractSpeechText(text)
console.log('📤 Sending text to TTS server via GET:', speechText.substring(0, 100) + '...');
// 构建GET请求URL
const url = `${this.httpUrl}?text=${encodeURIComponent(speechText)}`
console.log('🔗 Final GET URL:', url);
// #ifdef MP-WEIXIN
// 微信小程序环境,使用背景音频管理器
const isBackgroundAudioAvailable = this.initAudioManager()
// 重置音频状态
this.isSpeaking.value = true
this.isPaused.value = false
this.isComplete.value = false
if (isBackgroundAudioAvailable && this.backgroundAudioManager) {
console.log('🎵 微信小程序使用背景音频管理器播放URL:', url);
// 设置背景音频参数
this.backgroundAudioManager.title = 'AI语音播报'
this.backgroundAudioManager.singer = 'KS AI'
this.backgroundAudioManager.coverImgUrl = '/static/icon/logo.png'
// 直接设置src并播放
this.backgroundAudioManager.src = url
console.log('🎵 微信小程序背景音频开始播放');
} else {
// 降级方案使用InnerAudioContext
console.log('🔄 微信小程序背景音频不可用降级使用InnerAudioContext');
// 如果已有音频上下文,先销毁再重新创建
if (this.innerAudioContext) {
this.innerAudioContext.destroy()
this.innerAudioContext = null
}
this.innerAudioContext = uni.createInnerAudioContext()
this.innerAudioContext.autoplay = false
this.innerAudioContext.obeyMuteSwitch = false
this.innerAudioContext.volume = 1.0
this.innerAudioContext.onPlay(() => {
console.log('🎵 微信小程序InnerAudioContext播放开始')
this.isSpeaking.value = true
this.isPaused.value = false
})
this.innerAudioContext.onPause(() => {
console.log('⏸️ 微信小程序InnerAudioContext播放暂停')
this.isPaused.value = true
})
this.innerAudioContext.onStop(() => {
console.log('⏹️ 微信小程序InnerAudioContext播放停止')
this.isSpeaking.value = false
this.isComplete.value = true
})
this.innerAudioContext.onEnded(() => {
console.log('🎵 微信小程序InnerAudioContext播放结束')
this.isSpeaking.value = false
this.isComplete.value = true
})
this.innerAudioContext.onError((res) => {
console.error('❌ 微信小程序InnerAudioContext错误:', res.errMsg, '错误码:', res.errCode)
this.isSpeaking.value = false
this.isComplete.value = false
})
this.innerAudioContext.onCanplay(() => {
console.log('🎵 微信小程序InnerAudioContext可以播放了')
if (this.isSpeaking.value && !this.isPaused.value) {
this.innerAudioContext.play()
}
})
this.innerAudioContext.src = url
console.log('🎵 微信小程序InnerAudioContext开始播放');
}
// #endif
// #ifdef H5
// H5环境使用 AudioContext
try {
// 创建新的AbortController用于取消当前请求
this.abortController = new AbortController();
const signal = this.abortController.signal;
// 确保AudioContext已创建
if (!this.audioContext) {
console.log('🎵 H5: 创建新的AudioContext');
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
// 检查并恢复AudioContext状态浏览器安全策略要求用户交互后才能播放音频
if (this.audioContext.state === 'suspended') {
console.log('🎵 H5: 恢复挂起的AudioContext');
await this.audioContext.resume();
console.log('✅ H5: AudioContext已恢复状态:', this.audioContext.state);
}
// 发送GET请求获取语音数据添加signal支持取消
const response = await fetch(url, { signal })
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
// 获取二进制数据
const arrayBuffer = await response.arrayBuffer()
console.log('✅ H5: Received audio data, size:', arrayBuffer.byteLength + ' bytes');
try {
// 直接使用 audioContext.decodeAudioData 解码,不依赖外部库
const decoded = await this.audioContext.decodeAudioData(arrayBuffer)
console.log('✅ H5: Audio decoded, sampleRate:', decoded.sampleRate, 'channels:', decoded.numberOfChannels);
// 播放音频
this.playDecodedAudio(decoded)
} catch (decodeError) {
console.error('❌ H5: AudioContext decodeAudioData failed:', decodeError);
// 降级处理:创建一个简单的音频缓冲区
this.createFallbackAudio(arrayBuffer)
}
} catch (h5Error) {
// 检查是否是取消请求导致的错误
if (h5Error.name === 'AbortError') {
console.log('✅ H5: Fetch request aborted as requested');
this.isSpeaking.value = false;
this.isComplete.value = false;
return;
}
console.error('❌ H5: Audio playback failed:', h5Error);
// 尝试使用HTML5 Audio元素作为最终降级方案
try {
console.log('🔄 H5: 尝试使用HTML5 Audio元素播放');
// 如果已有Audio元素先停止并销毁
if (this.htmlAudioElement) {
this.htmlAudioElement.pause();
this.htmlAudioElement.src = '';
this.htmlAudioElement = null;
}
const audio = new Audio(url);
this.htmlAudioElement = audio;
audio.play();
console.log('✅ H5: HTML5 Audio元素开始播放');
// 设置音频状态
this.isSpeaking.value = true;
this.isPaused.value = false;
this.isComplete.value = false;
// 监听播放结束
audio.onended = () => {
console.log('🎵 H5: HTML5 Audio播放结束');
this.isSpeaking.value = false;
this.isComplete.value = true;
this.htmlAudioElement = null;
};
// 监听播放错误
audio.onerror = (error) => {
console.error('❌ H5: HTML5 Audio播放错误:', error);
this.isSpeaking.value = false;
this.isComplete.value = false;
this.htmlAudioElement = null;
};
} catch (audioError) {
console.error('❌ H5: HTML5 Audio播放也失败了:', audioError);
this.isSpeaking.value = false;
this.isComplete.value = false;
this.htmlAudioElement = null;
}
} finally {
// 清除AbortController因为请求已经完成无论成功还是失败
this.abortController = null;
}
// #endif
} catch (error) {
console.error('❌ TTS synthesis failed:', error);
this.isSpeaking.value = false
this.isComplete.value = false
} finally {
// 标记请求处理完成
this.isProcessingRequest = false
}
}
}
// 导出单例hook
export function useTTSPlayer(httpUrl) {
// 如果已经有实例,直接返回
if (!ttsInstance) {
ttsInstance = new TTSPlayer(httpUrl)
}
// 返回实例的方法和状态
return {
speak: ttsInstance.speak.bind(ttsInstance),
pause: ttsInstance.pause.bind(ttsInstance),
resume: ttsInstance.resume.bind(ttsInstance),
cancelAudio: ttsInstance.cancelAudio.bind(ttsInstance),
isSpeaking: ttsInstance.isSpeaking,
isPaused: ttsInstance.isPaused,
isComplete: ttsInstance.isComplete
}
}
function extractSpeechText(markdown) {
console.log('🔍 extractSpeechText called');
console.log('📝 Input markdown length:', markdown ? markdown.length : 0);
console.log('📝 Input markdown preview:', markdown ? markdown.substring(0, 200) + '...' : 'No markdown');
const jobRegex = /``` job-json\s*({[\s\S]*?})\s*```/g;
const jobs = [];
let match;
let lastJobEndIndex = 0;
let firstJobStartIndex = -1;
// 提取岗位 json 数据及前后位置
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;
console.log('✅ Found job:', job.jobTitle);
} catch (e) {
console.warn('JSON 解析失败', e);
}
}
console.log('📊 Jobs found:', jobs.length);
console.log('📍 First job start index:', firstJobStartIndex);
console.log('📍 Last job end index:', lastJobEndIndex);
// 提取引导语(第一个 job-json 之前的文字)
const guideText = firstJobStartIndex > 0 ?
markdown.slice(0, firstJobStartIndex).trim() :
'';
// 提取结束语(最后一个 job-json 之后的文字)
const endingText = lastJobEndIndex < markdown.length ?
markdown.slice(lastJobEndIndex).trim() :
'';
console.log('📝 Guide text:', guideText);
console.log('📝 Ending text:', endingText);
// 岗位信息格式化为语音文本
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);
const finalText = finalTextParts.join('\n');
console.log('🎤 Final TTS text length:', finalText.length);
console.log('🎤 Final TTS text preview:', finalText.substring(0, 200) + '...');
console.log('🎤 Final TTS text parts count:', finalTextParts.length);
return finalText;
}