mirror of
https://github.com/Megghy/vtsuru.live.git
synced 2025-12-07 02:46:55 +08:00
Compare commits
2 Commits
4b4fb8d87e
...
26273a4afc
| Author | SHA1 | Date | |
|---|---|---|---|
| 26273a4afc | |||
| ad277bc1aa |
File diff suppressed because it is too large
Load Diff
9
src/components.d.ts
vendored
9
src/components.d.ts
vendored
@@ -18,13 +18,18 @@ declare module 'vue' {
|
|||||||
LabelItem: typeof import('./components/LabelItem.vue')['default']
|
LabelItem: typeof import('./components/LabelItem.vue')['default']
|
||||||
LiveInfoContainer: typeof import('./components/LiveInfoContainer.vue')['default']
|
LiveInfoContainer: typeof import('./components/LiveInfoContainer.vue')['default']
|
||||||
MonacoEditorComponent: typeof import('./components/MonacoEditorComponent.vue')['default']
|
MonacoEditorComponent: typeof import('./components/MonacoEditorComponent.vue')['default']
|
||||||
NEllipsis: typeof import('naive-ui')['NEllipsis']
|
NAvatar: typeof import('naive-ui')['NAvatar']
|
||||||
NEmpty: typeof import('naive-ui')['NEmpty']
|
NButton: typeof import('naive-ui')['NButton']
|
||||||
|
NCard: typeof import('naive-ui')['NCard']
|
||||||
NFlex: typeof import('naive-ui')['NFlex']
|
NFlex: typeof import('naive-ui')['NFlex']
|
||||||
NFormItemGi: typeof import('naive-ui')['NFormItemGi']
|
NFormItemGi: typeof import('naive-ui')['NFormItemGi']
|
||||||
NGridItem: typeof import('naive-ui')['NGridItem']
|
NGridItem: typeof import('naive-ui')['NGridItem']
|
||||||
NIcon: typeof import('naive-ui')['NIcon']
|
NIcon: typeof import('naive-ui')['NIcon']
|
||||||
|
NImage: typeof import('naive-ui')['NImage']
|
||||||
|
NPopconfirm: typeof import('naive-ui')['NPopconfirm']
|
||||||
NScrollbar: typeof import('naive-ui')['NScrollbar']
|
NScrollbar: typeof import('naive-ui')['NScrollbar']
|
||||||
|
NSpace: typeof import('naive-ui')['NSpace']
|
||||||
|
NSwitch: typeof import('naive-ui')['NSwitch']
|
||||||
NTag: typeof import('naive-ui')['NTag']
|
NTag: typeof import('naive-ui')['NTag']
|
||||||
NText: typeof import('naive-ui')['NText']
|
NText: typeof import('naive-ui')['NText']
|
||||||
PointGoodsItem: typeof import('./components/manage/PointGoodsItem.vue')['default']
|
PointGoodsItem: typeof import('./components/manage/PointGoodsItem.vue')['default']
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export const THINGS_URL = `${FILE_BASE_URL}/things/`
|
|||||||
export const apiFail = ref(false)
|
export const apiFail = ref(false)
|
||||||
|
|
||||||
export const BASE_URL
|
export const BASE_URL
|
||||||
= process.env.NODE_ENV === 'development'
|
= import.meta.env.NODE_ENV === 'development'
|
||||||
? debugAPI
|
? debugAPI
|
||||||
: apiFail.value
|
: apiFail.value
|
||||||
? failoverAPI
|
? failoverAPI
|
||||||
@@ -27,7 +27,7 @@ export const BASE_URL
|
|||||||
export const BASE_API_URL = `${BASE_URL}api/`
|
export const BASE_API_URL = `${BASE_URL}api/`
|
||||||
export const FETCH_API = 'https://fetch.vtsuru.live/'
|
export const FETCH_API = 'https://fetch.vtsuru.live/'
|
||||||
export const BASE_HUB_URL
|
export const BASE_HUB_URL
|
||||||
= `${process.env.NODE_ENV === 'development'
|
= `${import.meta.env.NODE_ENV === 'development'
|
||||||
? debugAPI
|
? debugAPI
|
||||||
: apiFail.value
|
: apiFail.value
|
||||||
? failoverAPI
|
? 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 USER_CONFIG_API_URL = `${BASE_API_URL}user-config/`
|
||||||
export const FILE_API_URL = `${BASE_API_URL}files/`
|
export const FILE_API_URL = `${BASE_API_URL}files/`
|
||||||
export const VOTE_API_URL = `${BASE_API_URL}vote/`
|
export const VOTE_API_URL = `${BASE_API_URL}vote/`
|
||||||
|
export const TTS_API_URL = `${BASE_API_URL}tts/`
|
||||||
|
|
||||||
export interface TemplateMapType {
|
export interface TemplateMapType {
|
||||||
[key: string]: {
|
[key: string]: {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { clearInterval, setInterval } from 'worker-timers'
|
|||||||
import type { EventModel } from '@/api/api-models'
|
import type { EventModel } from '@/api/api-models'
|
||||||
import { DownloadConfig, UploadConfig, useAccount } from '@/api/account'
|
import { DownloadConfig, UploadConfig, useAccount } from '@/api/account'
|
||||||
import { EventDataTypes } from '@/api/api-models'
|
import { EventDataTypes } from '@/api/api-models'
|
||||||
import { FETCH_API } from '@/data/constants'
|
import { FETCH_API, TTS_API_URL } from '@/data/constants'
|
||||||
|
|
||||||
export interface SpeechSettings {
|
export interface SpeechSettings {
|
||||||
speechInfo: SpeechInfo
|
speechInfo: SpeechInfo
|
||||||
@@ -16,12 +16,15 @@ export interface SpeechSettings {
|
|||||||
guardTemplate: string
|
guardTemplate: string
|
||||||
giftTemplate: string
|
giftTemplate: string
|
||||||
enterTemplate: string
|
enterTemplate: string
|
||||||
voiceType: 'local' | 'api'
|
voiceType: 'local' | 'api' | 'azure'
|
||||||
voiceAPISchemeType: 'http' | 'https'
|
voiceAPISchemeType: 'http' | 'https'
|
||||||
voiceAPI: string
|
voiceAPI: string
|
||||||
splitText: boolean
|
splitText: boolean
|
||||||
useAPIDirectly: boolean
|
useAPIDirectly: boolean
|
||||||
combineGiftDelay: number | undefined
|
combineGiftDelay: number | undefined
|
||||||
|
azureVoice: string
|
||||||
|
azureLanguage: string
|
||||||
|
outputDeviceId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SpeechInfo {
|
export interface SpeechInfo {
|
||||||
@@ -65,6 +68,9 @@ const DEFAULT_SETTINGS: SpeechSettings = {
|
|||||||
useAPIDirectly: false,
|
useAPIDirectly: false,
|
||||||
splitText: false,
|
splitText: false,
|
||||||
combineGiftDelay: 2,
|
combineGiftDelay: 2,
|
||||||
|
azureVoice: 'zh-CN-XiaoxiaoNeural',
|
||||||
|
azureLanguage: 'zh-CN',
|
||||||
|
outputDeviceId: 'default',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const templateConstants = {
|
export const templateConstants = {
|
||||||
@@ -134,6 +140,7 @@ function createSpeechService() {
|
|||||||
|
|
||||||
const apiAudio = ref<HTMLAudioElement>()
|
const apiAudio = ref<HTMLAudioElement>()
|
||||||
let checkTimer: number | undefined
|
let checkTimer: number | undefined
|
||||||
|
let loadingTimeoutTimer: number | undefined // 音频加载超时计时器
|
||||||
let speechQueueTimer: number | undefined
|
let speechQueueTimer: number | undefined
|
||||||
|
|
||||||
const speechSynthesisInfo = ref<{
|
const speechSynthesisInfo = ref<{
|
||||||
@@ -204,6 +211,11 @@ function createSpeechService() {
|
|||||||
checkTimer = undefined
|
checkTimer = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (loadingTimeoutTimer) {
|
||||||
|
clearInterval(loadingTimeoutTimer)
|
||||||
|
loadingTimeoutTimer = undefined
|
||||||
|
}
|
||||||
|
|
||||||
cancelSpeech()
|
cancelSpeech()
|
||||||
giftCombineMap.clear()
|
giftCombineMap.clear()
|
||||||
speakQueue.value = []
|
speakQueue.value = []
|
||||||
@@ -294,10 +306,7 @@ function createSpeechService() {
|
|||||||
text = text.replace(templateConstants.guard_num.regex, (data.num ?? 0).toString())
|
text = text.replace(templateConstants.guard_num.regex, (data.num ?? 0).toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
text = fullWidthToHalfWidth(text)
|
console.log(text)
|
||||||
.replace(/[^0-9a-z\u4E00-\u9FFF\u3400-\u4DBF\uF900-\uFAFF,.:'"\s]/gi, '')
|
|
||||||
.normalize('NFKC')
|
|
||||||
|
|
||||||
return text
|
return text
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,6 +368,13 @@ function createSpeechService() {
|
|||||||
* 构建API请求URL
|
* 构建API请求URL
|
||||||
*/
|
*/
|
||||||
function buildApiUrl(text: string): string | null {
|
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) {
|
if (!settings.value.voiceAPI) {
|
||||||
message.error('未设置语音API')
|
message.error('未设置语音API')
|
||||||
return null
|
return null
|
||||||
@@ -400,15 +416,47 @@ function createSpeechService() {
|
|||||||
* 使用API TTS朗读
|
* 使用API TTS朗读
|
||||||
*/
|
*/
|
||||||
function speakFromAPI(text: string) {
|
function speakFromAPI(text: string) {
|
||||||
const url = buildApiUrl(text)
|
let url = buildApiUrl(text)
|
||||||
if (!url) {
|
if (!url) {
|
||||||
cancelSpeech()
|
cancelSpeech()
|
||||||
return
|
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.isSpeaking = true
|
||||||
speechState.isApiAudioLoading = true
|
speechState.isApiAudioLoading = true
|
||||||
|
|
||||||
|
// 先清空 apiAudioSrc,确保 audio 元素能够正确重新加载
|
||||||
|
// 这样可以避免连续播放时 src 更新不触发加载的问题
|
||||||
|
speechState.apiAudioSrc = ''
|
||||||
|
|
||||||
|
// 使用 nextTick 确保 DOM 更新后再设置新的 src
|
||||||
|
// 但由于这是在 store 中,我们使用 setTimeout 来模拟
|
||||||
|
setTimeout(() => {
|
||||||
speechState.apiAudioSrc = url
|
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 +518,10 @@ function createSpeechService() {
|
|||||||
if (settings.value.voiceType == 'local') {
|
if (settings.value.voiceType == 'local') {
|
||||||
speakDirect(text)
|
speakDirect(text)
|
||||||
} else {
|
} else {
|
||||||
text = settings.value.splitText ? insertSpaces(text) : text
|
// 只有自定义 API 且启用了 splitText 才进行文本拆分
|
||||||
|
if (settings.value.voiceType === 'api' && settings.value.splitText) {
|
||||||
|
text = insertSpaces(text)
|
||||||
|
}
|
||||||
speakFromAPI(text)
|
speakFromAPI(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -489,16 +540,34 @@ function createSpeechService() {
|
|||||||
checkTimer = undefined
|
checkTimer = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (loadingTimeoutTimer) {
|
||||||
|
clearInterval(loadingTimeoutTimer)
|
||||||
|
loadingTimeoutTimer = undefined
|
||||||
|
}
|
||||||
|
|
||||||
speechState.isApiAudioLoading = false
|
speechState.isApiAudioLoading = false
|
||||||
|
|
||||||
if (apiAudio.value && !apiAudio.value.paused) {
|
if (apiAudio.value && !apiAudio.value.paused) {
|
||||||
apiAudio.value.pause()
|
apiAudio.value.pause()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 清空音频源,确保下次播放时能正确加载新的音频
|
||||||
|
speechState.apiAudioSrc = ''
|
||||||
|
|
||||||
EasySpeech.cancel()
|
EasySpeech.cancel()
|
||||||
speechState.speakingText = ''
|
speechState.speakingText = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除音频加载超时计时器
|
||||||
|
*/
|
||||||
|
function clearLoadingTimeout() {
|
||||||
|
if (loadingTimeoutTimer) {
|
||||||
|
clearInterval(loadingTimeoutTimer)
|
||||||
|
loadingTimeoutTimer = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 接收事件并添加到队列
|
* 接收事件并添加到队列
|
||||||
*/
|
*/
|
||||||
@@ -680,6 +749,7 @@ function createSpeechService() {
|
|||||||
startSpeech,
|
startSpeech,
|
||||||
stopSpeech,
|
stopSpeech,
|
||||||
cancelSpeech,
|
cancelSpeech,
|
||||||
|
clearLoadingTimeout,
|
||||||
uploadConfig,
|
uploadConfig,
|
||||||
downloadConfig,
|
downloadConfig,
|
||||||
getTextFromDanmaku,
|
getTextFromDanmaku,
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import { EventDataTypes } from '@/api/api-models'
|
|||||||
import { useDanmakuClient } from '@/store/useDanmakuClient'
|
import { useDanmakuClient } from '@/store/useDanmakuClient'
|
||||||
import { templateConstants, useSpeechService } from '@/store/useSpeechService'
|
import { templateConstants, useSpeechService } from '@/store/useSpeechService'
|
||||||
import { copyToClipboard } from '@/Utils'
|
import { copyToClipboard } from '@/Utils'
|
||||||
|
import { TTS_API_URL } from '@/data/constants';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
roomInfo?: any
|
roomInfo?: any
|
||||||
@@ -68,6 +69,14 @@ const {
|
|||||||
apiAudio,
|
apiAudio,
|
||||||
} = speechService
|
} = speechService
|
||||||
|
|
||||||
|
// Azure 语音列表
|
||||||
|
const azureVoices = ref<Array<{ label: string; value: string; locale: string }>>([])
|
||||||
|
const azureVoicesLoading = ref(false)
|
||||||
|
|
||||||
|
// 音频输出设备列表
|
||||||
|
const audioOutputDevices = ref<Array<{ label: string; value: string }>>([])
|
||||||
|
const audioOutputDevicesLoading = ref(false)
|
||||||
|
|
||||||
// 计算属性
|
// 计算属性
|
||||||
const isVtsuruVoiceAPI = computed(() => {
|
const isVtsuruVoiceAPI = computed(() => {
|
||||||
return (
|
return (
|
||||||
@@ -197,6 +206,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) {
|
function getEventTypeTag(type: EventDataTypes) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case EventDataTypes.Message:
|
case EventDataTypes.Message:
|
||||||
@@ -220,6 +284,68 @@ function onAPIError(_e: Event) {
|
|||||||
cancelSpeech()
|
cancelSpeech()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onAudioCanPlay() {
|
||||||
|
speechState.isApiAudioLoading = false
|
||||||
|
speechService.clearLoadingTimeout()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAudioError(e: Event) {
|
||||||
|
speechService.clearLoadingTimeout()
|
||||||
|
onAPIError(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取音频输出设备列表
|
||||||
|
*/
|
||||||
|
async function fetchAudioOutputDevices() {
|
||||||
|
audioOutputDevicesLoading.value = true
|
||||||
|
try {
|
||||||
|
if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) {
|
||||||
|
message.warning('当前浏览器不支持设备枚举')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const devices = await navigator.mediaDevices.enumerateDevices()
|
||||||
|
const outputDevices = devices.filter(device => device.kind === 'audiooutput')
|
||||||
|
|
||||||
|
audioOutputDevices.value = [
|
||||||
|
{ label: '默认设备', value: 'default' },
|
||||||
|
...outputDevices.map(device => ({
|
||||||
|
label: device.label || `设备 ${device.deviceId.substring(0, 8)}`,
|
||||||
|
value: device.deviceId,
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
|
||||||
|
console.log('[TTS] 音频输出设备列表:', audioOutputDevices.value)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[TTS] 获取音频输出设备失败:', error)
|
||||||
|
message.error('获取音频输出设备失败,可能需要授予麦克风权限')
|
||||||
|
} finally {
|
||||||
|
audioOutputDevicesLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置音频元素的输出设备
|
||||||
|
*/
|
||||||
|
async function setAudioOutputDevice() {
|
||||||
|
if (!apiAudio.value || !settings.value.outputDeviceId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (typeof apiAudio.value.setSinkId === 'function') {
|
||||||
|
await apiAudio.value.setSinkId(settings.value.outputDeviceId)
|
||||||
|
console.log(`[TTS] 已切换到输出设备: ${settings.value.outputDeviceId}`)
|
||||||
|
} else {
|
||||||
|
console.warn('[TTS] 当前浏览器不支持选择输出设备')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[TTS] 设置输出设备失败:', error)
|
||||||
|
message.error('设置输出设备失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 生命周期
|
// 生命周期
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await speechService.initialize()
|
await speechService.initialize()
|
||||||
@@ -229,6 +355,19 @@ onMounted(async () => {
|
|||||||
client.onEvent('guard', onGetEvent)
|
client.onEvent('guard', onGetEvent)
|
||||||
client.onEvent('gift', onGetEvent)
|
client.onEvent('gift', onGetEvent)
|
||||||
client.onEvent('enter', onGetEvent)
|
client.onEvent('enter', onGetEvent)
|
||||||
|
|
||||||
|
// 如果默认使用 Azure TTS,则预加载语音列表
|
||||||
|
if (settings.value.voiceType === 'azure') {
|
||||||
|
fetchAzureVoices()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取音频输出设备列表
|
||||||
|
await fetchAudioOutputDevices()
|
||||||
|
|
||||||
|
// 监听输出设备变化
|
||||||
|
if (navigator.mediaDevices) {
|
||||||
|
navigator.mediaDevices.addEventListener('devicechange', fetchAudioOutputDevices)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -239,6 +378,11 @@ onUnmounted(() => {
|
|||||||
client.offEvent('enter', onGetEvent)
|
client.offEvent('enter', onGetEvent)
|
||||||
|
|
||||||
speechService.stopSpeech()
|
speechService.stopSpeech()
|
||||||
|
|
||||||
|
// 移除设备变化监听器
|
||||||
|
if (navigator.mediaDevices) {
|
||||||
|
navigator.mediaDevices.removeEventListener('devicechange', fetchAudioOutputDevices)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -627,6 +771,47 @@ onUnmounted(() => {
|
|||||||
vertical
|
vertical
|
||||||
:size="16"
|
:size="16"
|
||||||
>
|
>
|
||||||
|
<!-- 输出设备选择 -->
|
||||||
|
<div>
|
||||||
|
<NSpace justify="space-between" align="center">
|
||||||
|
<NText strong>输出设备</NText>
|
||||||
|
<NButton
|
||||||
|
v-if="audioOutputDevices.length === 0"
|
||||||
|
text
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
:loading="audioOutputDevicesLoading"
|
||||||
|
@click="fetchAudioOutputDevices"
|
||||||
|
>
|
||||||
|
加载设备列表
|
||||||
|
</NButton>
|
||||||
|
</NSpace>
|
||||||
|
<NSelect
|
||||||
|
v-model:value="settings.outputDeviceId"
|
||||||
|
:options="audioOutputDevices"
|
||||||
|
:loading="audioOutputDevicesLoading"
|
||||||
|
:fallback-option="() => ({
|
||||||
|
label: settings.outputDeviceId === 'default' ? '默认设备' : `已选择: ${settings.outputDeviceId.substring(0, 16)}...`,
|
||||||
|
value: settings.outputDeviceId || 'default',
|
||||||
|
})"
|
||||||
|
style="margin-top: 8px"
|
||||||
|
@update:value="setAudioOutputDevice"
|
||||||
|
/>
|
||||||
|
<NAlert
|
||||||
|
v-if="audioOutputDevices.length === 1"
|
||||||
|
type="info"
|
||||||
|
:bordered="false"
|
||||||
|
style="margin-top: 8px; font-size: 12px"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<NIcon :component="Info24Filled" :size="16" />
|
||||||
|
</template>
|
||||||
|
未检测到其他音频设备。某些浏览器需要授予麦克风权限才能列出所有设备。
|
||||||
|
</NAlert>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NDivider style="margin: 8px 0" />
|
||||||
|
|
||||||
<NRadioGroup
|
<NRadioGroup
|
||||||
v-model:value="settings.voiceType"
|
v-model:value="settings.voiceType"
|
||||||
size="large"
|
size="large"
|
||||||
@@ -646,6 +831,21 @@ onUnmounted(() => {
|
|||||||
</NSpace>
|
</NSpace>
|
||||||
</NRadioButton>
|
</NRadioButton>
|
||||||
|
|
||||||
|
<NRadioButton value="azure">
|
||||||
|
<NSpace :size="4">
|
||||||
|
<span>Azure TTS</span>
|
||||||
|
<NTooltip>
|
||||||
|
<template #trigger>
|
||||||
|
<NIcon
|
||||||
|
:component="Info24Filled"
|
||||||
|
:size="16"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
使用 Microsoft Azure 语音合成服务, 混合语言输出效果和音质好, 略有延迟
|
||||||
|
</NTooltip>
|
||||||
|
</NSpace>
|
||||||
|
</NRadioButton>
|
||||||
|
|
||||||
<NRadioButton value="api">
|
<NRadioButton value="api">
|
||||||
<NSpace :size="4">
|
<NSpace :size="4">
|
||||||
<span>API 语音</span>
|
<span>API 语音</span>
|
||||||
@@ -744,6 +944,127 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</NSpace>
|
</NSpace>
|
||||||
|
|
||||||
|
<!-- Azure TTS 设置 -->
|
||||||
|
<NSpace
|
||||||
|
v-else-if="settings.voiceType === 'azure'"
|
||||||
|
vertical
|
||||||
|
:size="16"
|
||||||
|
>
|
||||||
|
<NAlert
|
||||||
|
type="success"
|
||||||
|
:bordered="false"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<NIcon :component="Info24Filled" />
|
||||||
|
</template>
|
||||||
|
使用本站提供的 Microsoft Azure 语音合成服务,效果最好
|
||||||
|
</NAlert>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<NSpace justify="space-between" align="center">
|
||||||
|
<NText strong>语音选择</NText>
|
||||||
|
<NButton
|
||||||
|
v-if="azureVoices.length === 0"
|
||||||
|
text
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
:loading="azureVoicesLoading"
|
||||||
|
@click="fetchAzureVoices"
|
||||||
|
>
|
||||||
|
加载语音列表
|
||||||
|
</NButton>
|
||||||
|
<NText v-else depth="3" style="font-size: 12px">
|
||||||
|
共 {{ azureVoices.length }} 个语音
|
||||||
|
</NText>
|
||||||
|
</NSpace>
|
||||||
|
<NSelect
|
||||||
|
v-model:value="settings.azureVoice"
|
||||||
|
:options="azureVoices.length > 0 ? azureVoices : [
|
||||||
|
{ label: '中文(普通话)女 - 晓晓', value: 'zh-CN-XiaoxiaoNeural' },
|
||||||
|
{ label: '中文(普通话)女 - 晓伊', value: 'zh-CN-XiaoyiNeural' },
|
||||||
|
{ label: '中文(普通话)女 - 晓梦', value: 'zh-CN-XiaomengNeural' },
|
||||||
|
{ label: '中文(普通话)女 - 晓莫', value: 'zh-CN-XiaomoNeural' },
|
||||||
|
{ label: '中文(普通话)女 - 晓秋', value: 'zh-CN-XiaoqiuNeural' },
|
||||||
|
{ label: '中文(普通话)女 - 晓双', value: 'zh-CN-XiaoshuangNeural' },
|
||||||
|
{ label: '中文(普通话)女 - 晓纯', value: 'zh-CN-XiaochenNeural' },
|
||||||
|
{ label: '中文(普通话)女 - 晓翔', value: 'zh-CN-XiaoxiangNeural' },
|
||||||
|
{ label: '中文(普通话)女 - 晓蕾', value: 'zh-CN-XiaorouNeural' },
|
||||||
|
{ label: '中文(普通话)女 - 晓瑶', value: 'zh-CN-XiaoyouNeural' },
|
||||||
|
{ label: '中文(普通话)男 - 云希', value: 'zh-CN-YunxiNeural' },
|
||||||
|
{ label: '中文(普通话)男 - 云扬', value: 'zh-CN-YunyangNeural' },
|
||||||
|
{ label: '中文(普通话)男 - 云健', value: 'zh-CN-YunjianNeural' },
|
||||||
|
{ label: '中文(普通话)儿童 - 晓晋', value: 'zh-CN-XiaozhenNeural' },
|
||||||
|
{ label: '中文(普通话)儿童 - 云夏', value: 'zh-CN-YunxiaNeural' },
|
||||||
|
]"
|
||||||
|
:loading="azureVoicesLoading"
|
||||||
|
:fallback-option="() => ({
|
||||||
|
label: settings.azureVoice ? `已选择: ${settings.azureVoice}` : '未选择',
|
||||||
|
value: settings.azureVoice || '',
|
||||||
|
})"
|
||||||
|
style="margin-top: 8px"
|
||||||
|
filterable
|
||||||
|
@focus="fetchAzureVoices"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<NSpace
|
||||||
|
justify="space-between"
|
||||||
|
align="center"
|
||||||
|
>
|
||||||
|
<NText>音量</NText>
|
||||||
|
<NText depth="3">
|
||||||
|
{{ (settings.speechInfo.volume * 100).toFixed(0) }}%
|
||||||
|
</NText>
|
||||||
|
</NSpace>
|
||||||
|
<NSlider
|
||||||
|
v-model:value="settings.speechInfo.volume"
|
||||||
|
:min="0"
|
||||||
|
:max="1"
|
||||||
|
:step="0.01"
|
||||||
|
style="margin-top: 8px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<NSpace
|
||||||
|
justify="space-between"
|
||||||
|
align="center"
|
||||||
|
>
|
||||||
|
<NText>音调</NText>
|
||||||
|
<NText depth="3">
|
||||||
|
{{ settings.speechInfo.pitch.toFixed(2) }}
|
||||||
|
</NText>
|
||||||
|
</NSpace>
|
||||||
|
<NSlider
|
||||||
|
v-model:value="settings.speechInfo.pitch"
|
||||||
|
:min="0.5"
|
||||||
|
:max="2"
|
||||||
|
:step="0.01"
|
||||||
|
style="margin-top: 8px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<NSpace
|
||||||
|
justify="space-between"
|
||||||
|
align="center"
|
||||||
|
>
|
||||||
|
<NText>语速</NText>
|
||||||
|
<NText depth="3">
|
||||||
|
{{ settings.speechInfo.rate.toFixed(2) }}
|
||||||
|
</NText>
|
||||||
|
</NSpace>
|
||||||
|
<NSlider
|
||||||
|
v-model:value="settings.speechInfo.rate"
|
||||||
|
:min="0.5"
|
||||||
|
:max="2"
|
||||||
|
:step="0.01"
|
||||||
|
style="margin-top: 8px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</NSpace>
|
||||||
|
|
||||||
<!-- API 语音设置 -->
|
<!-- API 语音设置 -->
|
||||||
<NSpace
|
<NSpace
|
||||||
v-else
|
v-else
|
||||||
@@ -865,20 +1186,23 @@ onUnmounted(() => {
|
|||||||
style="margin-top: 8px"
|
style="margin-top: 8px"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</NSpace>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
<!-- 隐藏的音频元素 -->
|
<!-- 隐藏的音频元素 - 用于 API 和 Azure TTS -->
|
||||||
<audio
|
<audio
|
||||||
|
v-if="settings.voiceType !== 'local'"
|
||||||
ref="apiAudio"
|
ref="apiAudio"
|
||||||
:src="speechState.apiAudioSrc"
|
:src="speechState.apiAudioSrc"
|
||||||
:volume="settings.speechInfo.volume"
|
:volume="settings.speechInfo.volume"
|
||||||
style="display: none"
|
style="display: none"
|
||||||
|
autoplay
|
||||||
@ended="cancelSpeech"
|
@ended="cancelSpeech"
|
||||||
@canplay="speechState.isApiAudioLoading = false"
|
@canplay="onAudioCanPlay"
|
||||||
@error="onAPIError"
|
@error="onAudioError"
|
||||||
|
@loadedmetadata="setAudioOutputDevice"
|
||||||
/>
|
/>
|
||||||
</NSpace>
|
</NSpace>
|
||||||
</Transition>
|
|
||||||
</NSpace>
|
|
||||||
</NCard>
|
</NCard>
|
||||||
|
|
||||||
<!-- 模板设置区域 -->
|
<!-- 模板设置区域 -->
|
||||||
@@ -1063,7 +1387,10 @@ onUnmounted(() => {
|
|||||||
</NInputGroup>
|
</NInputGroup>
|
||||||
</NSpace>
|
</NSpace>
|
||||||
|
|
||||||
<NCheckbox v-model:checked="settings.splitText">
|
<NCheckbox
|
||||||
|
v-if="settings.voiceType === 'api'"
|
||||||
|
v-model:checked="settings.splitText"
|
||||||
|
>
|
||||||
<NSpace
|
<NSpace
|
||||||
:size="4"
|
:size="4"
|
||||||
align="center"
|
align="center"
|
||||||
|
|||||||
Reference in New Issue
Block a user