diff --git a/frontend/app/web-gold/src/composables/useTTS.js b/frontend/app/web-gold/src/composables/useTTS.js index a21e90629e..cbf2e36dc9 100644 --- a/frontend/app/web-gold/src/composables/useTTS.js +++ b/frontend/app/web-gold/src/composables/useTTS.js @@ -1,172 +1,147 @@ /** - * TTS (Text-to-Speech) 公共Hook + * TTS (Text-to-Speech) 公共 Hook * 支持多个供应商:CosyVoice, SiliconFlow, Azure, AWS等 */ -import { ref, computed } from 'vue' +import { ref } 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'] } -/** 最大预览缓存数量 */ const MAX_PREVIEW_CACHE_SIZE = 50 -// ========== 类型定义 ========== +// ========== 工具函数 ========== /** - * @typedef {Object} AudioData - * @property {Blob} blob - 音频 Blob - * @property {string} objectUrl - 对象 URL - * @property {string} format - 音频格式 + * 从字符串提取 ID + * @param {string} idStr - 格式如 'user-123' + * @returns {number|null} */ +function extractIdFromString(idStr) { + if (typeof idStr !== 'string' || !idStr.startsWith('user-')) return null + const id = parseInt(idStr.replace('user-', '')) + return Number.isNaN(id) ? null : id +} + +/** + * 解码 Base64 音频为 Blob + * @param {string} base64 - Base64 编码的音频 + * @param {string} format - 音频格式 + * @returns {{ blob: Blob, objectUrl: string }} + */ +function decodeBase64Audio(base64, format = 'mp3') { + const chars = window.atob(base64) + const bytes = new Uint8Array(chars.length) + for (let i = 0; i < chars.length; i++) { + bytes[i] = chars.charCodeAt(i) + } + const mime = format === 'mp3' ? 'audio/mpeg' : `audio/${format}` + const blob = new Blob([bytes], { type: mime }) + return { blob, objectUrl: URL.createObjectURL(blob) } +} + +/** + * 生成缓存键 + */ +function generateCacheKey(voiceId, text, rate) { + return `${voiceId}:${text}:${rate}` +} + +// ========== useTTS 主函数 ========== export function useTTS(options = {}) { - const { - provider = VOICE_PROVIDER_TYPES.SILICONFLOW, - customConfig = {} - } = options + const { provider = VOICE_PROVIDER_TYPES.SILICONFLOW } = options - // 状态管理(移到函数内部,避免模块级状态污染) - const previewAudioCache = new Map() - const previewLoadingVoiceId = ref(null) - const playingPreviewVoiceId = ref(null) + // ----- 状态 ----- const ttsText = ref('') const speechRate = ref(1.0) + const previewLoadingVoiceId = ref(null) + const playingPreviewVoiceId = ref(null) - // 音频实例(移到函数内部) + // ----- 音频实例 ----- let previewAudio = null let previewObjectUrl = '' + const previewAudioCache = new Map() - // ========== 辅助函数 ========== - - function getProviderConfig() { - return DEFAULT_CONFIG - } + // ----- 音频播放 ----- /** - * 播放音频预览 + * 播放音频 * @param {string} url - 音频 URL - * @param {Object} playOptions - 播放选项 - * @param {boolean} [playOptions.revokeOnEnd=false] - 播放结束后是否释放 URL - * @param {Function} [playOptions.onEnded] - 播放结束回调 + * @param {Object} opts - 选项 + * @param {boolean} [opts.revokeOnEnd] - 播放结束后释放 URL + * @param {Function} [opts.onEnded] - 播放结束回调 */ - function playAudioPreview(url, playOptions = {}) { + function playAudioPreview(url, opts = {}) { if (!url) { message.warning('暂无可试听的音频') return } - // 停止当前播放 - try { - previewAudio?.pause?.() - previewAudio = null - } catch { - // 忽略停止播放的错误 - } + stopCurrentPlayback() const audio = new Audio(url) - - function cleanup() { - if (playOptions.revokeOnEnd && url.startsWith('blob:')) { + const cleanup = () => { + if (opts.revokeOnEnd && url.startsWith('blob:')) { URL.revokeObjectURL(url) - if (previewObjectUrl === url) { - previewObjectUrl = '' - } + if (previewObjectUrl === url) previewObjectUrl = '' } previewAudio = null - playOptions.onEnded?.() + opts.onEnded?.() } audio.play() .then(() => { previewAudio = audio audio.onended = cleanup - audio.onerror = () => { - cleanup() - message.error('播放失败') - } - }) - .catch(() => { - cleanup() - message.error('播放失败') + audio.onerror = () => { cleanup(); message.error('播放失败') } }) + .catch(() => { cleanup(); message.error('播放失败') }) } /** - * 生成预览缓存键 - * @param {Object} voice - 音色对象 - * @returns {string} 缓存键 + * 停止当前播放 */ - function generatePreviewCacheKey(voice) { - const voiceId = voice.voiceId || voice.rawId || voice.id - const text = ttsText.value.trim() - const rate = speechRate.value - return `${voiceId}:${text}:${rate}` + function stopCurrentPlayback() { + try { + previewAudio?.pause?.() + previewAudio = null + } catch { /* ignore */ } } /** - * 解码并缓存Base64音频 - * @param {string} audioBase64 - Base64 编码的音频数据 - * @param {string} [format='mp3'] - 音频格式 - * @param {string} cacheKey - 缓存键 - * @returns {Promise} 音频数据 + * 清除音频缓存 */ - async function decodeAndCacheBase64(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) + function clearAudioCache() { + previewAudioCache.forEach(data => URL.revokeObjectURL(data.objectUrl)) + previewAudioCache.clear() + } - const audioData = { blob, objectUrl, format } + // ----- 缓存管理 ----- + /** + * 缓存音频数据 + */ + function cacheAudio(cacheKey, audioData) { 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 - 播放结束回调 - */ - function playCachedAudio(audioData, onEnded) { - if (previewObjectUrl && previewObjectUrl !== audioData.objectUrl) { - URL.revokeObjectURL(previewObjectUrl) - } - previewObjectUrl = audioData.objectUrl - - playAudioPreview(previewObjectUrl, { - revokeOnEnd: false, - onEnded() { - if (audioData.objectUrl?.startsWith('blob:')) { - URL.revokeObjectURL(audioData.objectUrl) - } - onEnded?.() - } - }) - } + // ----- 预览/试听 ----- /** * 重置预览状态 @@ -176,21 +151,8 @@ export function useTTS(options = {}) { playingPreviewVoiceId.value = null } - /** - * 提取ID从字符串 - * @param {string} idStr - 包含前缀的ID字符串 - * @returns {number|null} 提取的ID - */ - function 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} 预览参数 */ function buildPreviewParams(voice) { const configId = voice.rawId || extractIdFromString(voice.id) @@ -198,53 +160,64 @@ export function useTTS(options = {}) { message.error('配音配置无效') return null } - const providerConfig = getProviderConfig() - return { voiceConfigId: configId, inputText: ttsText.value, speechRate: speechRate.value || 1.0, - audioFormat: providerConfig.audioFormat, + audioFormat: DEFAULT_CONFIG.audioFormat, timestamp: Date.now(), providerType: normalizeProviderType(provider) } } + /** + * 播放缓存的音频 + */ + function playCachedAudio(audioData) { + if (previewObjectUrl && previewObjectUrl !== audioData.objectUrl) { + URL.revokeObjectURL(previewObjectUrl) + } + previewObjectUrl = audioData.objectUrl + playAudioPreview(previewObjectUrl, { + revokeOnEnd: false, + onEnded: () => { + if (audioData.objectUrl?.startsWith('blob:')) { + URL.revokeObjectURL(audioData.objectUrl) + } + } + }) + } + /** * 播放音色试听 * @param {Object} voice - 音色对象 * @param {Function} onSuccess - 成功回调 * @param {Function} onError - 错误回调 - * @param {Object} options - 选项 - * @param {boolean} options.autoPlay - 是否自动播放(默认 true) + * @param {Object} opts - 选项 { autoPlay: boolean } */ - async function playVoiceSample(voice, onSuccess, onError, options = { autoPlay: true }) { + async function playVoiceSample(voice, onSuccess, onError, opts = { autoPlay: true }) { if (!voice) return - if (previewLoadingVoiceId.value === voice.id || playingPreviewVoiceId.value === voice.id) { - return - } + + const voiceId = voice.voiceId || voice.rawId || voice.id + const cacheKey = generateCacheKey(voiceId, ttsText.value.trim(), speechRate.value) + + // 防止重复点击 + if (previewLoadingVoiceId.value === voice.id || playingPreviewVoiceId.value === voice.id) return + + // 停止其他播放 if (playingPreviewVoiceId.value && playingPreviewVoiceId.value !== voice.id) { - try { - previewAudio?.pause?.() - previewAudio = null - } catch { - // 忽略错误 - } + stopCurrentPlayback() } previewLoadingVoiceId.value = voice.id playingPreviewVoiceId.value = voice.id - const cacheKey = generatePreviewCacheKey(voice) - const cachedAudio = previewAudioCache.get(cacheKey) - - if (cachedAudio) { - if (options.autoPlay !== false) { - playCachedAudio(cachedAudio, resetPreviewState) - } else { - resetPreviewState() - } - onSuccess?.(cachedAudio) + // 命中缓存 + const cached = previewAudioCache.get(cacheKey) + if (cached) { + if (opts.autoPlay !== false) playCachedAudio(cached) + resetPreviewState() + onSuccess?.(cached) return } @@ -264,31 +237,30 @@ export function useTTS(options = {}) { return } + // 处理音频 URL if (res.data?.audioUrl) { resetPreviewState() - if (options.autoPlay !== false) { - playAudioPreview(res.data.audioUrl, { - revokeOnEnd: true, - onEnded() { - URL.revokeObjectURL(res.data.audioUrl) - } - }) + if (opts.autoPlay !== false) { + playAudioPreview(res.data.audioUrl, { revokeOnEnd: true }) } onSuccess?.(res.data) - } else if (res.data?.audioBase64) { - const audioData = await decodeAndCacheBase64(res.data.audioBase64, res.data.format, cacheKey) - resetPreviewState() - if (options.autoPlay !== false) { - playCachedAudio(audioData, () => { - URL.revokeObjectURL(audioData.objectUrl) - }) - } - onSuccess?.(audioData) - } else { - message.error('试听失败') - resetPreviewState() - onError?.(new Error('未收到音频数据')) + return } + + // 处理 Base64 音频 + if (res.data?.audioBase64) { + const { blob, objectUrl } = decodeBase64Audio(res.data.audioBase64, res.data.format) + const audioData = { blob, objectUrl, format: res.data.format } + cacheAudio(cacheKey, audioData) + resetPreviewState() + if (opts.autoPlay !== false) playCachedAudio(audioData) + onSuccess?.(audioData) + return + } + + message.error('试听失败') + resetPreviewState() + onError?.(new Error('未收到音频数据')) } catch (error) { message.error('试听失败') resetPreviewState() @@ -296,71 +268,41 @@ export function useTTS(options = {}) { } } + // ----- TTS 合成 ----- + /** - * TTS文本转语音 + * TTS 文本转语音 * @param {Object} params - TTS 参数 - * @returns {Promise} TTS 结果 + * @returns {Promise} */ async function synthesize(params) { - const providerConfig = getProviderConfig() - - const ttsParams = { + return VoiceService.synthesize({ inputText: params.inputText || ttsText.value, voiceConfigId: params.voiceConfigId, speechRate: params.speechRate || speechRate.value, - audioFormat: params.audioFormat || providerConfig.audioFormat, + audioFormat: params.audioFormat || DEFAULT_CONFIG.audioFormat, providerType: normalizeProviderType(provider) - } - - return await VoiceService.synthesize(ttsParams) + }) } - /** - * 设置文本 - * @param {string} text - 要设置的文本 - */ + // ----- Setters ----- + function setText(text) { ttsText.value = text } - /** - * 设置语速 - * @param {number} rate - 语速倍率 - */ function setSpeechRate(rate) { speechRate.value = rate } - /** - * 清除音频缓存 - */ - function clearAudioCache() { - previewAudioCache.forEach((audioData) => { - URL.revokeObjectURL(audioData.objectUrl) - }) - previewAudioCache.clear() - } - - /** - * 停止当前播放 - */ - function stopCurrentPlayback() { - try { - previewAudio?.pause?.() - previewAudio = null - } catch { - // 忽略错误 - } - } - - // ========== 返回接口 ========== + // ----- 返回接口 ----- return { // 状态 - previewLoadingVoiceId, - playingPreviewVoiceId, ttsText, speechRate, + previewLoadingVoiceId, + playingPreviewVoiceId, // 方法 playVoiceSample, @@ -373,9 +315,9 @@ export function useTTS(options = {}) { resetPreviewState, // 配置 - getProviderConfig, TTS_PROVIDERS, - DEFAULT_CONFIG + DEFAULT_CONFIG, + getProviderConfig: () => DEFAULT_CONFIG } } diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileServiceImpl.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileServiceImpl.java index 4dd5a2a4ac..d890baa866 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileServiceImpl.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileServiceImpl.java @@ -336,7 +336,8 @@ public class TikUserFileServiceImpl implements TikUserFileService { throw exception(FILE_CATEGORY_INVALID, "文件不是视频类型"); } - return getCachedPresignUrl(file.getFileUrl(), PRESIGN_URL_EXPIRATION_SECONDS); + // 视频播放URL不缓存,每次都生成新的签名URL + return fileApi.presignGetUrl(file.getFileUrl(), PRESIGN_URL_EXPIRATION_SECONDS); } @Override @@ -347,7 +348,8 @@ public class TikUserFileServiceImpl implements TikUserFileService { throw exception(FILE_CATEGORY_INVALID, "文件不是音频类型"); } - return getCachedPresignUrl(file.getFileUrl(), PRESIGN_URL_EXPIRATION_SECONDS); + // 音频播放URL不缓存,每次都生成新的签名URL + return fileApi.presignGetUrl(file.getFileUrl(), PRESIGN_URL_EXPIRATION_SECONDS); } @Override