feat:优化

This commit is contained in:
2026-03-05 21:22:27 +08:00
parent c07a61c424
commit e046335900
2 changed files with 146 additions and 202 deletions

View File

@@ -1,172 +1,147 @@
/** /**
* TTS (Text-to-Speech) 公共Hook * TTS (Text-to-Speech) 公共 Hook
* 支持多个供应商CosyVoice, SiliconFlow, Azure, AWS等 * 支持多个供应商CosyVoice, SiliconFlow, Azure, AWS等
*/ */
import { ref, computed } from 'vue' import { ref } from 'vue'
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import { VoiceService } from '@/api/voice' import { VoiceService } from '@/api/voice'
import { normalizeProviderType, VOICE_PROVIDER_TYPES } from '@/config/voiceConfig' import { normalizeProviderType, VOICE_PROVIDER_TYPES } from '@/config/voiceConfig'
// ========== 常量 ========== // ========== 常量 ==========
/** 兼容旧代码的导出 */
const TTS_PROVIDERS = VOICE_PROVIDER_TYPES const TTS_PROVIDERS = VOICE_PROVIDER_TYPES
/** 默认配置 */
const DEFAULT_CONFIG = { const DEFAULT_CONFIG = {
apiEndpoint: '/api/tik/voice/tts', apiEndpoint: '/api/tik/voice/tts',
audioFormat: 'mp3', audioFormat: 'mp3',
supportedFormats: ['mp3', 'wav'] supportedFormats: ['mp3', 'wav']
} }
/** 最大预览缓存数量 */
const MAX_PREVIEW_CACHE_SIZE = 50 const MAX_PREVIEW_CACHE_SIZE = 50
// ========== 类型定义 ========== // ========== 工具函数 ==========
/** /**
* @typedef {Object} AudioData * 从字符串提取 ID
* @property {Blob} blob - 音频 Blob * @param {string} idStr - 格式如 'user-123'
* @property {string} objectUrl - 对象 URL * @returns {number|null}
* @property {string} format - 音频格式
*/ */
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 = {}) { export function useTTS(options = {}) {
const { const { provider = VOICE_PROVIDER_TYPES.SILICONFLOW } = options
provider = VOICE_PROVIDER_TYPES.SILICONFLOW,
customConfig = {}
} = options
// 状态管理(移到函数内部,避免模块级状态污染) // ----- 状态 -----
const previewAudioCache = new Map()
const previewLoadingVoiceId = ref(null)
const playingPreviewVoiceId = ref(null)
const ttsText = ref('') const ttsText = ref('')
const speechRate = ref(1.0) const speechRate = ref(1.0)
const previewLoadingVoiceId = ref(null)
const playingPreviewVoiceId = ref(null)
// 音频实例(移到函数内部) // ----- 音频实例 -----
let previewAudio = null let previewAudio = null
let previewObjectUrl = '' let previewObjectUrl = ''
const previewAudioCache = new Map()
// ========== 辅助函数 ========== // ----- 音频播放 -----
function getProviderConfig() {
return DEFAULT_CONFIG
}
/** /**
* 播放音频预览 * 播放音频
* @param {string} url - 音频 URL * @param {string} url - 音频 URL
* @param {Object} playOptions - 播放选项 * @param {Object} opts - 选项
* @param {boolean} [playOptions.revokeOnEnd=false] - 播放结束后是否释放 URL * @param {boolean} [opts.revokeOnEnd] - 播放结束后释放 URL
* @param {Function} [playOptions.onEnded] - 播放结束回调 * @param {Function} [opts.onEnded] - 播放结束回调
*/ */
function playAudioPreview(url, playOptions = {}) { function playAudioPreview(url, opts = {}) {
if (!url) { if (!url) {
message.warning('暂无可试听的音频') message.warning('暂无可试听的音频')
return return
} }
// 停止当前播放 stopCurrentPlayback()
try {
previewAudio?.pause?.()
previewAudio = null
} catch {
// 忽略停止播放的错误
}
const audio = new Audio(url) const audio = new Audio(url)
const cleanup = () => {
function cleanup() { if (opts.revokeOnEnd && url.startsWith('blob:')) {
if (playOptions.revokeOnEnd && url.startsWith('blob:')) {
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
if (previewObjectUrl === url) { if (previewObjectUrl === url) previewObjectUrl = ''
previewObjectUrl = ''
}
} }
previewAudio = null previewAudio = null
playOptions.onEnded?.() opts.onEnded?.()
} }
audio.play() audio.play()
.then(() => { .then(() => {
previewAudio = audio previewAudio = audio
audio.onended = cleanup audio.onended = cleanup
audio.onerror = () => { audio.onerror = () => { cleanup(); message.error('播放失败') }
cleanup()
message.error('播放失败')
}
})
.catch(() => {
cleanup()
message.error('播放失败')
}) })
.catch(() => { cleanup(); message.error('播放失败') })
} }
/** /**
* 生成预览缓存键 * 停止当前播放
* @param {Object} voice - 音色对象
* @returns {string} 缓存键
*/ */
function generatePreviewCacheKey(voice) { function stopCurrentPlayback() {
const voiceId = voice.voiceId || voice.rawId || voice.id try {
const text = ttsText.value.trim() previewAudio?.pause?.()
const rate = speechRate.value previewAudio = null
return `${voiceId}:${text}:${rate}` } catch { /* ignore */ }
} }
/** /**
* 解码并缓存Base64音频 * 清除音频缓存
* @param {string} audioBase64 - Base64 编码的音频数据
* @param {string} [format='mp3'] - 音频格式
* @param {string} cacheKey - 缓存键
* @returns {Promise<Object>} 音频数据
*/ */
async function decodeAndCacheBase64(audioBase64, format = 'mp3', cacheKey) { function clearAudioCache() {
const byteCharacters = window.atob(audioBase64) previewAudioCache.forEach(data => URL.revokeObjectURL(data.objectUrl))
const byteNumbers = new Uint8Array(byteCharacters.length) previewAudioCache.clear()
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 } // ----- 缓存管理 -----
/**
* 缓存音频数据
*/
function cacheAudio(cacheKey, audioData) {
previewAudioCache.set(cacheKey, audioData) previewAudioCache.set(cacheKey, audioData)
if (previewAudioCache.size > MAX_PREVIEW_CACHE_SIZE) { if (previewAudioCache.size > MAX_PREVIEW_CACHE_SIZE) {
const firstKey = previewAudioCache.keys().next().value const firstKey = previewAudioCache.keys().next().value
const oldData = previewAudioCache.get(firstKey) const oldData = previewAudioCache.get(firstKey)
URL.revokeObjectURL(oldData.objectUrl) URL.revokeObjectURL(oldData.objectUrl)
previewAudioCache.delete(firstKey) 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 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) { function buildPreviewParams(voice) {
const configId = voice.rawId || extractIdFromString(voice.id) const configId = voice.rawId || extractIdFromString(voice.id)
@@ -198,53 +160,64 @@ export function useTTS(options = {}) {
message.error('配音配置无效') message.error('配音配置无效')
return null return null
} }
const providerConfig = getProviderConfig()
return { return {
voiceConfigId: configId, voiceConfigId: configId,
inputText: ttsText.value, inputText: ttsText.value,
speechRate: speechRate.value || 1.0, speechRate: speechRate.value || 1.0,
audioFormat: providerConfig.audioFormat, audioFormat: DEFAULT_CONFIG.audioFormat,
timestamp: Date.now(), timestamp: Date.now(),
providerType: normalizeProviderType(provider) 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 {Object} voice - 音色对象
* @param {Function} onSuccess - 成功回调 * @param {Function} onSuccess - 成功回调
* @param {Function} onError - 错误回调 * @param {Function} onError - 错误回调
* @param {Object} options - 选项 * @param {Object} opts - 选项 { autoPlay: boolean }
* @param {boolean} options.autoPlay - 是否自动播放(默认 true
*/ */
async function playVoiceSample(voice, onSuccess, onError, options = { autoPlay: true }) { async function playVoiceSample(voice, onSuccess, onError, opts = { autoPlay: true }) {
if (!voice) return 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) { if (playingPreviewVoiceId.value && playingPreviewVoiceId.value !== voice.id) {
try { stopCurrentPlayback()
previewAudio?.pause?.()
previewAudio = null
} catch {
// 忽略错误
}
} }
previewLoadingVoiceId.value = voice.id previewLoadingVoiceId.value = voice.id
playingPreviewVoiceId.value = voice.id playingPreviewVoiceId.value = voice.id
const cacheKey = generatePreviewCacheKey(voice) // 命中缓存
const cachedAudio = previewAudioCache.get(cacheKey) const cached = previewAudioCache.get(cacheKey)
if (cached) {
if (cachedAudio) { if (opts.autoPlay !== false) playCachedAudio(cached)
if (options.autoPlay !== false) {
playCachedAudio(cachedAudio, resetPreviewState)
} else {
resetPreviewState() resetPreviewState()
} onSuccess?.(cached)
onSuccess?.(cachedAudio)
return return
} }
@@ -264,31 +237,30 @@ export function useTTS(options = {}) {
return return
} }
// 处理音频 URL
if (res.data?.audioUrl) { if (res.data?.audioUrl) {
resetPreviewState() resetPreviewState()
if (options.autoPlay !== false) { if (opts.autoPlay !== false) {
playAudioPreview(res.data.audioUrl, { playAudioPreview(res.data.audioUrl, { revokeOnEnd: true })
revokeOnEnd: true,
onEnded() {
URL.revokeObjectURL(res.data.audioUrl)
}
})
} }
onSuccess?.(res.data) onSuccess?.(res.data)
} else if (res.data?.audioBase64) { return
const audioData = await decodeAndCacheBase64(res.data.audioBase64, res.data.format, cacheKey)
resetPreviewState()
if (options.autoPlay !== false) {
playCachedAudio(audioData, () => {
URL.revokeObjectURL(audioData.objectUrl)
})
} }
// 处理 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) onSuccess?.(audioData)
} else { return
}
message.error('试听失败') message.error('试听失败')
resetPreviewState() resetPreviewState()
onError?.(new Error('未收到音频数据')) onError?.(new Error('未收到音频数据'))
}
} catch (error) { } catch (error) {
message.error('试听失败') message.error('试听失败')
resetPreviewState() resetPreviewState()
@@ -296,71 +268,41 @@ export function useTTS(options = {}) {
} }
} }
// ----- TTS 合成 -----
/** /**
* TTS文本转语音 * TTS 文本转语音
* @param {Object} params - TTS 参数 * @param {Object} params - TTS 参数
* @returns {Promise<Object>} TTS 结果 * @returns {Promise<Object>}
*/ */
async function synthesize(params) { async function synthesize(params) {
const providerConfig = getProviderConfig() return VoiceService.synthesize({
const ttsParams = {
inputText: params.inputText || ttsText.value, inputText: params.inputText || ttsText.value,
voiceConfigId: params.voiceConfigId, voiceConfigId: params.voiceConfigId,
speechRate: params.speechRate || speechRate.value, speechRate: params.speechRate || speechRate.value,
audioFormat: params.audioFormat || providerConfig.audioFormat, audioFormat: params.audioFormat || DEFAULT_CONFIG.audioFormat,
providerType: normalizeProviderType(provider) providerType: normalizeProviderType(provider)
})
} }
return await VoiceService.synthesize(ttsParams) // ----- Setters -----
}
/**
* 设置文本
* @param {string} text - 要设置的文本
*/
function setText(text) { function setText(text) {
ttsText.value = text ttsText.value = text
} }
/**
* 设置语速
* @param {number} rate - 语速倍率
*/
function setSpeechRate(rate) { function setSpeechRate(rate) {
speechRate.value = rate speechRate.value = rate
} }
/** // ----- 返回接口 -----
* 清除音频缓存
*/
function clearAudioCache() {
previewAudioCache.forEach((audioData) => {
URL.revokeObjectURL(audioData.objectUrl)
})
previewAudioCache.clear()
}
/**
* 停止当前播放
*/
function stopCurrentPlayback() {
try {
previewAudio?.pause?.()
previewAudio = null
} catch {
// 忽略错误
}
}
// ========== 返回接口 ==========
return { return {
// 状态 // 状态
previewLoadingVoiceId,
playingPreviewVoiceId,
ttsText, ttsText,
speechRate, speechRate,
previewLoadingVoiceId,
playingPreviewVoiceId,
// 方法 // 方法
playVoiceSample, playVoiceSample,
@@ -373,9 +315,9 @@ export function useTTS(options = {}) {
resetPreviewState, resetPreviewState,
// 配置 // 配置
getProviderConfig,
TTS_PROVIDERS, TTS_PROVIDERS,
DEFAULT_CONFIG DEFAULT_CONFIG,
getProviderConfig: () => DEFAULT_CONFIG
} }
} }

View File

@@ -336,7 +336,8 @@ public class TikUserFileServiceImpl implements TikUserFileService {
throw exception(FILE_CATEGORY_INVALID, "文件不是视频类型"); 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 @Override
@@ -347,7 +348,8 @@ public class TikUserFileServiceImpl implements TikUserFileService {
throw exception(FILE_CATEGORY_INVALID, "文件不是音频类型"); 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 @Override