/** * 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} 音频数据 */ 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} 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 }