356 lines
8.9 KiB
JavaScript
356 lines
8.9 KiB
JavaScript
|
|
/**
|
|||
|
|
* TTS (Text-to-Speech) 公共Hook
|
|||
|
|
* 支持多个供应商:Qwen, Azure, AWS等
|
|||
|
|
*/
|
|||
|
|
import { ref, computed } from 'vue'
|
|||
|
|
import { message } from 'ant-design-vue'
|
|||
|
|
import { VoiceService } from '@/api/voice'
|
|||
|
|
|
|||
|
|
// 供应商配置
|
|||
|
|
const TTS_PROVIDERS = {
|
|||
|
|
QWEN: 'qwen',
|
|||
|
|
AZURE: 'azure',
|
|||
|
|
AWS: 'aws'
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 默认配置
|
|||
|
|
const DEFAULT_CONFIG = {
|
|||
|
|
qwen: {
|
|||
|
|
apiEndpoint: '/api/tik/voice/tts',
|
|||
|
|
audioFormat: 'mp3',
|
|||
|
|
supportedFormats: ['mp3', 'wav']
|
|||
|
|
},
|
|||
|
|
azure: {
|
|||
|
|
apiEndpoint: '/api/tik/voice/azure/tts',
|
|||
|
|
audioFormat: 'mp3',
|
|||
|
|
supportedFormats: ['mp3', 'wav', 'ogg']
|
|||
|
|
},
|
|||
|
|
aws: {
|
|||
|
|
apiEndpoint: '/api/tik/voice/aws/tts',
|
|||
|
|
audioFormat: 'mp3',
|
|||
|
|
supportedFormats: ['mp3', 'wav', 'ogg']
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* TTS Hook主函数
|
|||
|
|
* @param {Object} options 配置选项
|
|||
|
|
* @param {string} options.provider 供应商名称,默认'qwen'
|
|||
|
|
* @param {Object} options.customConfig 自定义配置
|
|||
|
|
* @returns {Object} TTS相关的方法和状态
|
|||
|
|
*/
|
|||
|
|
export function useTTS(options = {}) {
|
|||
|
|
const {
|
|||
|
|
provider = TTS_PROVIDERS.QWEN,
|
|||
|
|
customConfig = {}
|
|||
|
|
} = options
|
|||
|
|
|
|||
|
|
// 状态管理
|
|||
|
|
const previewAudioCache = new Map()
|
|||
|
|
const MAX_PREVIEW_CACHE_SIZE = 50
|
|||
|
|
const previewLoadingVoiceId = ref(null)
|
|||
|
|
const playingPreviewVoiceId = ref(null)
|
|||
|
|
const ttsText = ref('')
|
|||
|
|
const speechRate = ref(1.0)
|
|||
|
|
|
|||
|
|
// 音频实例
|
|||
|
|
let previewAudio = null
|
|||
|
|
let previewObjectUrl = ''
|
|||
|
|
|
|||
|
|
// 获取当前供应商配置
|
|||
|
|
const getProviderConfig = () => {
|
|||
|
|
const config = DEFAULT_CONFIG[provider] || DEFAULT_CONFIG[TTS_PROVIDERS.QWEN]
|
|||
|
|
return { ...config, ...customConfig }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 播放音频预览
|
|||
|
|
* @param {string} url 音频URL
|
|||
|
|
* @param {Object} options 播放选项
|
|||
|
|
*/
|
|||
|
|
const playAudioPreview = (url, options = {}) => {
|
|||
|
|
if (!url) return message.warning('暂无可试听的音频')
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
previewAudio?.pause?.()
|
|||
|
|
previewAudio = null
|
|||
|
|
} catch (error) {
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const audio = new Audio(url)
|
|||
|
|
const cleanup = () => {
|
|||
|
|
if (options.revokeOnEnd && url.startsWith('blob:')) {
|
|||
|
|
URL.revokeObjectURL(url)
|
|||
|
|
previewObjectUrl === url && (previewObjectUrl = '')
|
|||
|
|
}
|
|||
|
|
previewAudio = null
|
|||
|
|
options.onEnded && options.onEnded()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
audio.play()
|
|||
|
|
.then(() => {
|
|||
|
|
previewAudio = audio
|
|||
|
|
audio.onended = cleanup
|
|||
|
|
audio.onerror = () => {
|
|||
|
|
cleanup()
|
|||
|
|
message.error('播放失败')
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
.catch(err => {
|
|||
|
|
cleanup()
|
|||
|
|
message.error('播放失败')
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 生成预览缓存键
|
|||
|
|
* @param {Object} voice 音色对象
|
|||
|
|
* @returns {string} 缓存键
|
|||
|
|
*/
|
|||
|
|
const generatePreviewCacheKey = (voice) => {
|
|||
|
|
const voiceId = voice.voiceId || voice.rawId || voice.id
|
|||
|
|
const text = ttsText.value.trim()
|
|||
|
|
const rate = speechRate.value
|
|||
|
|
return `${voiceId}:${text}:${rate}`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 解码并缓存Base64音频
|
|||
|
|
* @param {string} audioBase64 Base64编码的音频数据
|
|||
|
|
* @param {string} format 音频格式
|
|||
|
|
* @param {string} cacheKey 缓存键
|
|||
|
|
* @returns {Promise<Object>} 音频数据
|
|||
|
|
*/
|
|||
|
|
const decodeAndCacheBase64 = async (audioBase64, format = 'mp3', cacheKey) => {
|
|||
|
|
const byteCharacters = window.atob(audioBase64)
|
|||
|
|
const byteNumbers = new Uint8Array(byteCharacters.length)
|
|||
|
|
for (let i = 0; i < byteCharacters.length; i++) {
|
|||
|
|
byteNumbers[i] = byteCharacters.charCodeAt(i)
|
|||
|
|
}
|
|||
|
|
const mime = format === 'mp3' ? 'audio/mpeg' : `audio/${format}`
|
|||
|
|
const blob = new Blob([byteNumbers], { type: mime })
|
|||
|
|
const objectUrl = URL.createObjectURL(blob)
|
|||
|
|
|
|||
|
|
const audioData = { blob, objectUrl, format }
|
|||
|
|
|
|||
|
|
previewAudioCache.set(cacheKey, audioData)
|
|||
|
|
|
|||
|
|
if (previewAudioCache.size > MAX_PREVIEW_CACHE_SIZE) {
|
|||
|
|
const firstKey = previewAudioCache.keys().next().value
|
|||
|
|
const oldData = previewAudioCache.get(firstKey)
|
|||
|
|
URL.revokeObjectURL(oldData.objectUrl)
|
|||
|
|
previewAudioCache.delete(firstKey)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return audioData
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 播放缓存的音频
|
|||
|
|
* @param {Object} audioData 音频数据
|
|||
|
|
* @param {Function} onEnded 播放结束回调
|
|||
|
|
*/
|
|||
|
|
const playCachedAudio = (audioData, onEnded) => {
|
|||
|
|
if (previewObjectUrl && previewObjectUrl !== audioData.objectUrl) {
|
|||
|
|
URL.revokeObjectURL(previewObjectUrl)
|
|||
|
|
}
|
|||
|
|
previewObjectUrl = audioData.objectUrl
|
|||
|
|
|
|||
|
|
playAudioPreview(previewObjectUrl, {
|
|||
|
|
revokeOnEnd: false,
|
|||
|
|
onEnded: () => {
|
|||
|
|
onEnded && onEnded()
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 重置预览状态
|
|||
|
|
*/
|
|||
|
|
const resetPreviewState = () => {
|
|||
|
|
previewLoadingVoiceId.value = null
|
|||
|
|
playingPreviewVoiceId.value = null
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 提取ID从字符串
|
|||
|
|
* @param {string} idStr 包含前缀的ID字符串
|
|||
|
|
* @returns {number|null} 提取的ID
|
|||
|
|
*/
|
|||
|
|
const extractIdFromString = (idStr) => {
|
|||
|
|
if (typeof idStr !== 'string' || !idStr.startsWith('user-')) return null
|
|||
|
|
const extractedId = parseInt(idStr.replace('user-', ''))
|
|||
|
|
return Number.isNaN(extractedId) ? null : extractedId
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 构建预览参数
|
|||
|
|
* @param {Object} voice 音色对象
|
|||
|
|
* @returns {Object|null} 预览参数
|
|||
|
|
*/
|
|||
|
|
const buildPreviewParams = (voice) => {
|
|||
|
|
const configId = voice.rawId || extractIdFromString(voice.id)
|
|||
|
|
if (!configId) {
|
|||
|
|
message.error('配音配置无效')
|
|||
|
|
return null
|
|||
|
|
}
|
|||
|
|
const providerConfig = getProviderConfig()
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
voiceConfigId: configId,
|
|||
|
|
inputText: ttsText.value,
|
|||
|
|
speechRate: speechRate.value || 1.0,
|
|||
|
|
audioFormat: providerConfig.audioFormat,
|
|||
|
|
timestamp: Date.now(),
|
|||
|
|
provider: provider
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 播放音色试听
|
|||
|
|
* @param {Object} voice 音色对象
|
|||
|
|
* @param {Function} onSuccess 成功回调
|
|||
|
|
* @param {Function} onError 错误回调
|
|||
|
|
*/
|
|||
|
|
const playVoiceSample = async (voice, onSuccess, onError) => {
|
|||
|
|
if (!voice) return
|
|||
|
|
if (previewLoadingVoiceId.value === voice.id || playingPreviewVoiceId.value === voice.id) {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if (playingPreviewVoiceId.value && playingPreviewVoiceId.value !== voice.id) {
|
|||
|
|
try {
|
|||
|
|
previewAudio?.pause?.()
|
|||
|
|
previewAudio = null
|
|||
|
|
} catch (error) {
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
previewLoadingVoiceId.value = voice.id
|
|||
|
|
playingPreviewVoiceId.value = voice.id
|
|||
|
|
|
|||
|
|
const cacheKey = generatePreviewCacheKey(voice)
|
|||
|
|
const cachedAudio = previewAudioCache.get(cacheKey)
|
|||
|
|
|
|||
|
|
if (cachedAudio) {
|
|||
|
|
playCachedAudio(cachedAudio, resetPreviewState)
|
|||
|
|
onSuccess && onSuccess(cachedAudio)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const params = buildPreviewParams(voice)
|
|||
|
|
if (!params) {
|
|||
|
|
resetPreviewState()
|
|||
|
|
onError && onError(new Error('参数构建失败'))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const res = await VoiceService.preview(params)
|
|||
|
|
if (res.code !== 0) {
|
|||
|
|
message.error(res.msg || '试听失败')
|
|||
|
|
resetPreviewState()
|
|||
|
|
onError && onError(new Error(res.msg || '试听失败'))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (res.data?.audioUrl) {
|
|||
|
|
playAudioPreview(res.data.audioUrl, { onEnded: resetPreviewState })
|
|||
|
|
onSuccess && onSuccess(res.data)
|
|||
|
|
} else if (res.data?.audioBase64) {
|
|||
|
|
const audioData = await decodeAndCacheBase64(res.data.audioBase64, res.data.format, cacheKey)
|
|||
|
|
playCachedAudio(audioData, resetPreviewState)
|
|||
|
|
onSuccess && onSuccess(audioData)
|
|||
|
|
} else {
|
|||
|
|
message.error('试听失败')
|
|||
|
|
resetPreviewState()
|
|||
|
|
onError && onError(new Error('未收到音频数据'))
|
|||
|
|
}
|
|||
|
|
} catch (error) {
|
|||
|
|
message.error('试听失败')
|
|||
|
|
resetPreviewState()
|
|||
|
|
onError && onError(error)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* TTS文本转语音
|
|||
|
|
* @param {Object} params TTS参数
|
|||
|
|
* @returns {Promise<Object>} TTS结果
|
|||
|
|
*/
|
|||
|
|
const synthesize = async (params) => {
|
|||
|
|
const providerConfig = getProviderConfig()
|
|||
|
|
|
|||
|
|
const ttsParams = {
|
|||
|
|
inputText: params.inputText || ttsText.value,
|
|||
|
|
voiceConfigId: params.voiceConfigId,
|
|||
|
|
speechRate: params.speechRate || speechRate.value,
|
|||
|
|
audioFormat: params.audioFormat || providerConfig.audioFormat,
|
|||
|
|
provider: provider
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return await VoiceService.synthesize(ttsParams)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 设置文本
|
|||
|
|
* @param {string} text 要设置的文本
|
|||
|
|
*/
|
|||
|
|
const setText = (text) => {
|
|||
|
|
ttsText.value = text
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 设置语速
|
|||
|
|
* @param {number} rate 语速倍率
|
|||
|
|
*/
|
|||
|
|
const setSpeechRate = (rate) => {
|
|||
|
|
speechRate.value = rate
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 清除音频缓存
|
|||
|
|
*/
|
|||
|
|
const clearAudioCache = () => {
|
|||
|
|
previewAudioCache.forEach((audioData) => {
|
|||
|
|
URL.revokeObjectURL(audioData.objectUrl)
|
|||
|
|
})
|
|||
|
|
previewAudioCache.clear()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 停止当前播放
|
|||
|
|
*/
|
|||
|
|
const stopCurrentPlayback = () => {
|
|||
|
|
try {
|
|||
|
|
previewAudio?.pause?.()
|
|||
|
|
previewAudio = null
|
|||
|
|
} catch (error) {
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
// 状态
|
|||
|
|
previewLoadingVoiceId,
|
|||
|
|
playingPreviewVoiceId,
|
|||
|
|
ttsText,
|
|||
|
|
speechRate,
|
|||
|
|
|
|||
|
|
// 方法
|
|||
|
|
playVoiceSample,
|
|||
|
|
synthesize,
|
|||
|
|
setText,
|
|||
|
|
setSpeechRate,
|
|||
|
|
playAudioPreview,
|
|||
|
|
clearAudioCache,
|
|||
|
|
stopCurrentPlayback,
|
|||
|
|
resetPreviewState,
|
|||
|
|
|
|||
|
|
// 配置
|
|||
|
|
getProviderConfig,
|
|||
|
|
TTS_PROVIDERS,
|
|||
|
|
DEFAULT_CONFIG
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export { TTS_PROVIDERS }
|