Files
sionrui/frontend/app/web-gold/src/composables/useTTS.js
2026-02-01 21:11:29 +08:00

332 lines
8.5 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.
/**
* TTS (Text-to-Speech) 公共Hook
* 支持多个供应商CosyVoice, SiliconFlow, Azure, AWS等
*/
import { ref, computed } from 'vue'
import { message } from 'ant-design-vue'
import { VoiceService } from '@/api/voice'
import { normalizeProviderType, VOICE_PROVIDER_TYPES } from '@/config/voiceConfig'
// 兼容旧代码的导出
const TTS_PROVIDERS = VOICE_PROVIDER_TYPES
const DEFAULT_CONFIG = {
apiEndpoint: '/api/tik/voice/tts',
audioFormat: 'mp3',
supportedFormats: ['mp3', 'wav']
}
export function useTTS(options = {}) {
const {
provider = VOICE_PROVIDER_TYPES.SILICONFLOW,
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 = () => {
return DEFAULT_CONFIG
}
/**
* 播放音频预览
* @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(),
providerType: normalizeProviderType(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,
providerType: normalizeProviderType(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 }