From ad277bc1aa63d12faef0faf15facfb0be5aa4ef2 Mon Sep 17 00:00:00 2001 From: Megghy Date: Mon, 13 Oct 2025 18:25:20 +0800 Subject: [PATCH] feat: Enhance TTS functionality with Azure support and UI improvements - Updated component declarations to include new Naive UI components. - Refactored environment variable access to use import.meta.env. - Added TTS_API_URL constant for Azure TTS integration. - Expanded SpeechSettings interface to support Azure voice and language options. - Implemented Azure TTS voice selection and loading mechanism in ReadDanmaku.vue. - Added loading timeout for audio playback and improved error handling. - Enhanced UI to allow users to select Azure voices and configure speech settings. --- src/client/ClientReadDanmaku.vue | 1244 +-------------------------- src/components.d.ts | 9 +- src/data/constants.ts | 5 +- src/store/useSpeechService.ts | 86 +- src/views/open_live/ReadDanmaku.vue | 240 +++++- 5 files changed, 318 insertions(+), 1266 deletions(-) diff --git a/src/client/ClientReadDanmaku.vue b/src/client/ClientReadDanmaku.vue index c90f65d..ba52a2a 100644 --- a/src/client/ClientReadDanmaku.vue +++ b/src/client/ClientReadDanmaku.vue @@ -1,1246 +1,8 @@ - - + + \ No newline at end of file diff --git a/src/components.d.ts b/src/components.d.ts index 986dd56..923672d 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -18,13 +18,18 @@ declare module 'vue' { LabelItem: typeof import('./components/LabelItem.vue')['default'] LiveInfoContainer: typeof import('./components/LiveInfoContainer.vue')['default'] MonacoEditorComponent: typeof import('./components/MonacoEditorComponent.vue')['default'] - NEllipsis: typeof import('naive-ui')['NEllipsis'] - NEmpty: typeof import('naive-ui')['NEmpty'] + NAvatar: typeof import('naive-ui')['NAvatar'] + NButton: typeof import('naive-ui')['NButton'] + NCard: typeof import('naive-ui')['NCard'] NFlex: typeof import('naive-ui')['NFlex'] NFormItemGi: typeof import('naive-ui')['NFormItemGi'] NGridItem: typeof import('naive-ui')['NGridItem'] NIcon: typeof import('naive-ui')['NIcon'] + NImage: typeof import('naive-ui')['NImage'] + NPopconfirm: typeof import('naive-ui')['NPopconfirm'] NScrollbar: typeof import('naive-ui')['NScrollbar'] + NSpace: typeof import('naive-ui')['NSpace'] + NSwitch: typeof import('naive-ui')['NSwitch'] NTag: typeof import('naive-ui')['NTag'] NText: typeof import('naive-ui')['NText'] PointGoodsItem: typeof import('./components/manage/PointGoodsItem.vue')['default'] diff --git a/src/data/constants.ts b/src/data/constants.ts index 7df8ff0..48201ce 100644 --- a/src/data/constants.ts +++ b/src/data/constants.ts @@ -19,7 +19,7 @@ export const THINGS_URL = `${FILE_BASE_URL}/things/` export const apiFail = ref(false) export const BASE_URL - = process.env.NODE_ENV === 'development' + = import.meta.env.NODE_ENV === 'development' ? debugAPI : apiFail.value ? failoverAPI @@ -27,7 +27,7 @@ export const BASE_URL export const BASE_API_URL = `${BASE_URL}api/` export const FETCH_API = 'https://fetch.vtsuru.live/' export const BASE_HUB_URL - = `${process.env.NODE_ENV === 'development' + = `${import.meta.env.NODE_ENV === 'development' ? debugAPI : apiFail.value ? failoverAPI @@ -65,6 +65,7 @@ export const CHECKIN_API_URL = `${BASE_API_URL}checkin/` export const USER_CONFIG_API_URL = `${BASE_API_URL}user-config/` export const FILE_API_URL = `${BASE_API_URL}files/` export const VOTE_API_URL = `${BASE_API_URL}vote/` +export const TTS_API_URL = `${BASE_API_URL}tts/` export interface TemplateMapType { [key: string]: { diff --git a/src/store/useSpeechService.ts b/src/store/useSpeechService.ts index a47b55b..3f5ad44 100644 --- a/src/store/useSpeechService.ts +++ b/src/store/useSpeechService.ts @@ -7,7 +7,7 @@ import { clearInterval, setInterval } from 'worker-timers' import type { EventModel } from '@/api/api-models' import { DownloadConfig, UploadConfig, useAccount } from '@/api/account' import { EventDataTypes } from '@/api/api-models' -import { FETCH_API } from '@/data/constants' +import { FETCH_API, TTS_API_URL } from '@/data/constants' export interface SpeechSettings { speechInfo: SpeechInfo @@ -16,12 +16,14 @@ export interface SpeechSettings { guardTemplate: string giftTemplate: string enterTemplate: string - voiceType: 'local' | 'api' + voiceType: 'local' | 'api' | 'azure' voiceAPISchemeType: 'http' | 'https' voiceAPI: string splitText: boolean useAPIDirectly: boolean combineGiftDelay: number | undefined + azureVoice: string + azureLanguage: string } export interface SpeechInfo { @@ -65,6 +67,8 @@ const DEFAULT_SETTINGS: SpeechSettings = { useAPIDirectly: false, splitText: false, combineGiftDelay: 2, + azureVoice: 'zh-CN-XiaoxiaoNeural', + azureLanguage: 'zh-CN', } export const templateConstants = { @@ -134,6 +138,7 @@ function createSpeechService() { const apiAudio = ref() let checkTimer: number | undefined + let loadingTimeoutTimer: number | undefined // 音频加载超时计时器 let speechQueueTimer: number | undefined const speechSynthesisInfo = ref<{ @@ -204,6 +209,11 @@ function createSpeechService() { checkTimer = undefined } + if (loadingTimeoutTimer) { + clearInterval(loadingTimeoutTimer) + loadingTimeoutTimer = undefined + } + cancelSpeech() giftCombineMap.clear() speakQueue.value = [] @@ -294,10 +304,7 @@ function createSpeechService() { text = text.replace(templateConstants.guard_num.regex, (data.num ?? 0).toString()) } - text = fullWidthToHalfWidth(text) - .replace(/[^0-9a-z\u4E00-\u9FFF\u3400-\u4DBF\uF900-\uFAFF,.:'"\s]/gi, '') - .normalize('NFKC') - + console.log(text) return text } @@ -359,6 +366,13 @@ function createSpeechService() { * 构建API请求URL */ function buildApiUrl(text: string): string | null { + // Azure TTS + if (settings.value.voiceType === 'azure') { + const apiUrl = `${TTS_API_URL}azure?text=${encodeURIComponent(text)}` + return apiUrl + } + + // 自定义 API if (!settings.value.voiceAPI) { message.error('未设置语音API') return null @@ -400,15 +414,47 @@ function createSpeechService() { * 使用API TTS朗读 */ function speakFromAPI(text: string) { - const url = buildApiUrl(text) + let url = buildApiUrl(text) if (!url) { cancelSpeech() return } + // 如果是 Azure TTS,添加额外参数 + if (settings.value.voiceType === 'azure') { + const azureUrl = new URL(url) + azureUrl.searchParams.set('voice', settings.value.azureVoice) + azureUrl.searchParams.set('language', settings.value.azureLanguage) + azureUrl.searchParams.set('rate', settings.value.speechInfo.rate.toString()) + azureUrl.searchParams.set('pitch', settings.value.speechInfo.pitch.toString()) + azureUrl.searchParams.set('streaming', 'true') + url = azureUrl.toString() + } + speechState.isSpeaking = true speechState.isApiAudioLoading = true - speechState.apiAudioSrc = url + + // 先清空 apiAudioSrc,确保 audio 元素能够正确重新加载 + // 这样可以避免连续播放时 src 更新不触发加载的问题 + speechState.apiAudioSrc = '' + + // 使用 nextTick 确保 DOM 更新后再设置新的 src + // 但由于这是在 store 中,我们使用 setTimeout 来模拟 + setTimeout(() => { + speechState.apiAudioSrc = url + }, 0) + + // 设置 10 秒加载超时 + if (loadingTimeoutTimer) { + clearInterval(loadingTimeoutTimer) + } + loadingTimeoutTimer = setInterval(() => { + if (speechState.isApiAudioLoading) { + console.error('[TTS] 音频加载超时 (10秒)') + message.error('音频加载超时,请检查网络连接或API状态') + cancelSpeech() + } + }, 10000) // 10 秒超时 } /** @@ -470,7 +516,10 @@ function createSpeechService() { if (settings.value.voiceType == 'local') { speakDirect(text) } else { - text = settings.value.splitText ? insertSpaces(text) : text + // 只有自定义 API 且启用了 splitText 才进行文本拆分 + if (settings.value.voiceType === 'api' && settings.value.splitText) { + text = insertSpaces(text) + } speakFromAPI(text) } @@ -489,16 +538,34 @@ function createSpeechService() { checkTimer = undefined } + if (loadingTimeoutTimer) { + clearInterval(loadingTimeoutTimer) + loadingTimeoutTimer = undefined + } + speechState.isApiAudioLoading = false if (apiAudio.value && !apiAudio.value.paused) { apiAudio.value.pause() } + // 清空音频源,确保下次播放时能正确加载新的音频 + speechState.apiAudioSrc = '' + EasySpeech.cancel() speechState.speakingText = '' } + /** + * 清除音频加载超时计时器 + */ + function clearLoadingTimeout() { + if (loadingTimeoutTimer) { + clearInterval(loadingTimeoutTimer) + loadingTimeoutTimer = undefined + } + } + /** * 接收事件并添加到队列 */ @@ -680,6 +747,7 @@ function createSpeechService() { startSpeech, stopSpeech, cancelSpeech, + clearLoadingTimeout, uploadConfig, downloadConfig, getTextFromDanmaku, diff --git a/src/views/open_live/ReadDanmaku.vue b/src/views/open_live/ReadDanmaku.vue index 54aa365..30c9aec 100644 --- a/src/views/open_live/ReadDanmaku.vue +++ b/src/views/open_live/ReadDanmaku.vue @@ -47,6 +47,7 @@ import { EventDataTypes } from '@/api/api-models' import { useDanmakuClient } from '@/store/useDanmakuClient' import { templateConstants, useSpeechService } from '@/store/useSpeechService' import { copyToClipboard } from '@/Utils' +import { TTS_API_URL } from '@/data/constants'; const props = defineProps<{ roomInfo?: any @@ -68,6 +69,10 @@ const { apiAudio, } = speechService +// Azure 语音列表 +const azureVoices = ref>([]) +const azureVoicesLoading = ref(false) + // 计算属性 const isVtsuruVoiceAPI = computed(() => { return ( @@ -197,6 +202,61 @@ function testAPI() { } } +/** + * 获取 Azure 语音列表 + */ +async function fetchAzureVoices() { + if (azureVoices.value.length > 0) { + return + } + + azureVoicesLoading.value = true + try { + const response = await fetch(`${TTS_API_URL}voices`) + if (!response.ok) { + throw new Error('获取语音列表失败') + } + + const voices = await response.json() + + azureVoices.value = voices + .filter((v: any) => { + const locale = v.Locale || v.locale || '' + return locale.startsWith('zh-') || locale.startsWith('ja-') || locale.startsWith('en-') + }) + .map((v: any) => { + const shortName = v.ShortName || v.shortName || '' + const localeName = v.LocaleName || v.localeName || '' + const localName = v.LocalName || v.localName || v.DisplayName || v.displayName || '' + const gender = v.Gender || v.gender || '' + const isMultilingual = shortName.toLowerCase().includes('multilingual') + + return { + label: `[${localeName}] ${localName} (${gender === 'Male' ? '男' : '女'})${isMultilingual ? ' 🌍' : ''}`, + value: shortName, + locale: v.Locale || v.locale || '', + } + }) + .sort((a: any, b: any) => { + // 多语言模型优先 + const aMulti = a.value.toLowerCase().includes('multilingual') + const bMulti = b.value.toLowerCase().includes('multilingual') + if (aMulti && !bMulti) return -1 + if (!aMulti && bMulti) return 1 + + // 然后按语言排序:中文排前面,日文其次,英文最后 + const aScore = a.locale.startsWith('zh-') ? 0 : a.locale.startsWith('ja-') ? 1 : 2 + const bScore = b.locale.startsWith('zh-') ? 0 : b.locale.startsWith('ja-') ? 1 : 2 + return aScore - bScore + }) + } catch (error) { + console.error('[Azure TTS] 获取语音列表失败:', error) + message.error('获取 Azure 语音列表失败') + } finally { + azureVoicesLoading.value = false + } +} + function getEventTypeTag(type: EventDataTypes) { switch (type) { case EventDataTypes.Message: @@ -220,6 +280,16 @@ function onAPIError(_e: Event) { cancelSpeech() } +function onAudioCanPlay() { + speechState.isApiAudioLoading = false + speechService.clearLoadingTimeout() +} + +function onAudioError(e: Event) { + speechService.clearLoadingTimeout() + onAPIError(e) +} + // 生命周期 onMounted(async () => { await speechService.initialize() @@ -229,6 +299,11 @@ onMounted(async () => { client.onEvent('guard', onGetEvent) client.onEvent('gift', onGetEvent) client.onEvent('enter', onGetEvent) + + // 如果默认使用 Azure TTS,则预加载语音列表 + if (settings.value.voiceType === 'azure') { + fetchAzureVoices() + } }) onUnmounted(() => { @@ -646,6 +721,21 @@ onUnmounted(() => { + + + Azure TTS + + + 使用 Microsoft Azure 语音合成服务, 混合语言输出效果和音质好, 略有延迟 + + + + API 语音 @@ -744,6 +834,127 @@ onUnmounted(() => { + + + + + 使用本站提供的 Microsoft Azure 语音合成服务,效果最好 + + +
+ + 语音选择 + + 加载语音列表 + + + 共 {{ azureVoices.length }} 个语音 + + + +
+ +
+ + 音量 + + {{ (settings.speechInfo.volume * 100).toFixed(0) }}% + + + +
+ +
+ + 音调 + + {{ settings.speechInfo.pitch.toFixed(2) }} + + + +
+ +
+ + 语速 + + {{ settings.speechInfo.rate.toFixed(2) }} + + + +
+
+ { style="margin-top: 8px" /> - - - + + +