mirror of
https://github.com/Megghy/vtsuru.live.git
synced 2025-12-06 18:36:55 +08:00
feat: 更新配置和组件以支持构建时间显示功能, 修复读弹幕的问题
- 在vite.config.mts中添加__BUILD_TIME__常量以记录构建时间 - 在AboutView.vue中显示构建时间及其相对时间 - 在ReadDanmaku.vue中重构语音合成状态管理,优化礼物合并逻辑 - 更新相关类型定义,增强代码可读性和可维护性
This commit is contained in:
2
src/auto-imports.d.ts
vendored
2
src/auto-imports.d.ts
vendored
@@ -553,6 +553,6 @@ declare global {
|
||||
// for type re-export
|
||||
declare global {
|
||||
// @ts-ignore
|
||||
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
|
||||
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
|
||||
import('vue')
|
||||
}
|
||||
|
||||
2
src/types/global.d.ts
vendored
2
src/types/global.d.ts
vendored
@@ -1 +1 @@
|
||||
|
||||
declare const __BUILD_TIME__: string;
|
||||
|
||||
@@ -1,7 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NCard, NDivider, NLayoutContent, NSpace, NText, NTimeline, NTimelineItem, NTag, NIcon } from 'naive-ui'
|
||||
import { h } from 'vue'
|
||||
import { h, computed } from 'vue'
|
||||
import { CodeOutline, ServerOutline, HeartOutline, LogoGithub } from '@vicons/ionicons5'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { zhCN } from 'date-fns/locale'
|
||||
|
||||
// 获取编译时间
|
||||
const buildTime = computed(() => {
|
||||
try {
|
||||
const buildDate = new Date(__BUILD_TIME__)
|
||||
return {
|
||||
date: buildDate.toLocaleString('zh-CN'),
|
||||
relative: formatDistanceToNow(buildDate, { addSuffix: true, locale: zhCN })
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
date: '未知',
|
||||
relative: '未知'
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<NLayoutContent style="height: 100vh; padding: 20px 0;">
|
||||
@@ -19,6 +37,9 @@ import { CodeOutline, ServerOutline, HeartOutline, LogoGithub } from '@vicons/io
|
||||
<div style="font-size: 22px; font-weight: bold; padding: 8px 0;">
|
||||
关于
|
||||
</div>
|
||||
<div style="font-size: 13px; color: #666; margin-top: 4px; padding-bottom: 8px;">
|
||||
构建时间: {{ buildTime.date }} ({{ buildTime.relative }})
|
||||
</div>
|
||||
</template>
|
||||
<NText>
|
||||
一个兴趣使然的网站
|
||||
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
NUl,
|
||||
useMessage,
|
||||
} from 'naive-ui'
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { computed, nextTick, onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { clearInterval, setInterval } from 'worker-timers'
|
||||
|
||||
@@ -57,12 +57,12 @@ type SpeechSettings = {
|
||||
enterTemplate: string
|
||||
voiceType: 'local' | 'api'
|
||||
voiceAPISchemeType: 'http' | 'https'
|
||||
voiceAPI?: string
|
||||
voiceAPI: string
|
||||
splitText: boolean
|
||||
useAPIDirectly: boolean
|
||||
|
||||
combineGiftDelay?: number
|
||||
combineGiftDelay: number | undefined
|
||||
}
|
||||
|
||||
type SpeechInfo = {
|
||||
volume: number
|
||||
pitch: number
|
||||
@@ -70,6 +70,22 @@ type SpeechInfo = {
|
||||
voice: string
|
||||
}
|
||||
|
||||
type SpeechState = {
|
||||
isSpeaking: boolean
|
||||
speakingText: string
|
||||
isApiAudioLoading: boolean
|
||||
apiAudioSrc: string
|
||||
canSpeech: boolean
|
||||
}
|
||||
|
||||
const speechState = reactive<SpeechState>({
|
||||
isSpeaking: false,
|
||||
speakingText: '',
|
||||
isApiAudioLoading: false,
|
||||
apiAudioSrc: '',
|
||||
canSpeech: false,
|
||||
})
|
||||
|
||||
const accountInfo = useAccount()
|
||||
const message = useMessage()
|
||||
const route = useRoute()
|
||||
@@ -91,7 +107,6 @@ const settings = useStorage<SpeechSettings>('Setting.Speech', {
|
||||
voiceAPI: 'voice.vtsuru.live/voice/bert-vits2?text={{text}}&id=1&format=mp3&streaming=true',
|
||||
useAPIDirectly: false,
|
||||
splitText: false,
|
||||
|
||||
combineGiftDelay: 2,
|
||||
})
|
||||
const speechSynthesisInfo = ref<{
|
||||
@@ -121,16 +136,15 @@ const voiceOptions = computed(() => {
|
||||
.DistinctBy((v) => v.value)
|
||||
.ToArray()
|
||||
})
|
||||
const isSpeaking = ref(false)
|
||||
const speakingText = ref('')
|
||||
const speakQueue = ref<{ updateAt: number; combineCount?: number; data: EventModel }[]>([])
|
||||
const giftCombineMap = new Map<string, number>() // 用于快速查找礼物合并项
|
||||
const MAX_QUEUE_SIZE = 50 // 最大队列长度限制
|
||||
const isVtsuruVoiceAPI = computed(() => {
|
||||
return (
|
||||
settings.value.voiceType == 'api' && settings.value.voiceAPI?.toLowerCase().trim().startsWith('voice.vtsuru.live')
|
||||
)
|
||||
})
|
||||
|
||||
const canSpeech = ref(false)
|
||||
const readedDanmaku = ref(0)
|
||||
|
||||
const templateConstants = {
|
||||
@@ -175,6 +189,22 @@ const templateConstants = {
|
||||
regex: /\{\s*gift_name\s*\}/gi,
|
||||
},
|
||||
}
|
||||
/**
|
||||
* 根据舰长等级数字返回对应的中文名称
|
||||
* @param guardLevel 舰长等级
|
||||
*/
|
||||
function getGuardLevelName(guardLevel: number): string {
|
||||
switch (guardLevel) {
|
||||
case 1: return '总督'
|
||||
case 2: return '提督'
|
||||
case 3: return '舰长'
|
||||
default: return ''
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 强制朗读指定事件, 会中断当前朗读并插队到最前面
|
||||
* @param data 事件数据
|
||||
*/
|
||||
function forceSpeak(data: EventModel) {
|
||||
cancelSpeech()
|
||||
|
||||
@@ -188,21 +218,55 @@ function forceSpeak(data: EventModel) {
|
||||
})
|
||||
}
|
||||
async function speak() {
|
||||
if (isSpeaking.value || speakQueue.value.length == 0) {
|
||||
if (speechState.isSpeaking || speakQueue.value.length == 0) {
|
||||
return
|
||||
}
|
||||
const data = speakQueue.value[0]
|
||||
if (
|
||||
data.data.type == EventDataTypes.Gift &&
|
||||
data.updateAt > Date.now() - (settings.value.combineGiftDelay ?? 0) * 1000
|
||||
) {
|
||||
|
||||
// 寻找可以立即播放的事件(优先非等待合并的礼物)
|
||||
let targetIndex = -1
|
||||
const now = Date.now()
|
||||
const combineDelay = (settings.value.combineGiftDelay ?? 0) * 1000
|
||||
|
||||
for (let i = 0; i < speakQueue.value.length; i++) {
|
||||
const item = speakQueue.value[i]
|
||||
|
||||
// 如果是礼物且还在等待合并期间,跳过
|
||||
if (item.data.type == EventDataTypes.Gift &&
|
||||
combineDelay > 0 &&
|
||||
item.updateAt > now - combineDelay) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 找到第一个可以播放的事件
|
||||
targetIndex = i
|
||||
break
|
||||
}
|
||||
|
||||
// 如果没有找到可播放的事件,直接返回
|
||||
if (targetIndex === -1) {
|
||||
return
|
||||
}
|
||||
let text = getTextFromDanmaku(speakQueue.value.shift()?.data)
|
||||
|
||||
// 获取要播放的事件并从队列中移除
|
||||
const targetItem = speakQueue.value.splice(targetIndex, 1)[0]
|
||||
|
||||
// 如果移除的不是礼物事件,需要更新礼物合并映射的索引
|
||||
if (targetItem.data.type !== EventDataTypes.Gift) {
|
||||
// 重建礼物合并映射,因为索引可能发生变化
|
||||
giftCombineMap.clear()
|
||||
speakQueue.value.forEach((item, index) => {
|
||||
if (item.data.type === EventDataTypes.Gift) {
|
||||
const giftKey = `${item.data.uid}-${item.data.msg}`
|
||||
giftCombineMap.set(giftKey, index)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
let text = getTextFromDanmaku(targetItem.data)
|
||||
if (text) {
|
||||
isSpeaking.value = true
|
||||
speechState.isSpeaking = true
|
||||
readedDanmaku.value++
|
||||
speakingText.value = text
|
||||
speechState.speakingText = text
|
||||
if (checkTimer) {
|
||||
clearInterval(checkTimer)
|
||||
}
|
||||
@@ -216,7 +280,7 @@ async function speak() {
|
||||
text = settings.value.splitText ? insertSpaces(text) : text
|
||||
speakFromAPI(text)
|
||||
}
|
||||
console.log(`[TTS] 正在朗读: ${text}`)
|
||||
console.log(`[TTS] 正在朗读: ${text} ${targetIndex > 0 ? '(跳过等待合并的礼物)' : ''}`)
|
||||
}
|
||||
}
|
||||
function insertSpaces(sentence: string) {
|
||||
@@ -234,6 +298,10 @@ function insertSpaces(sentence: string) {
|
||||
return sentence
|
||||
}
|
||||
let checkTimer: number | undefined
|
||||
/**
|
||||
* 使用浏览器本地语音合成功能朗读
|
||||
* @param text 要朗读的文本
|
||||
*/
|
||||
function speakDirect(text: string) {
|
||||
try {
|
||||
const synth = window.speechSynthesis
|
||||
@@ -266,109 +334,163 @@ function speakDirect(text: string) {
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
// 如果本地语音合成失败,确保清理定时器
|
||||
cancelSpeech()
|
||||
}
|
||||
}
|
||||
const apiAudio = ref<HTMLAudioElement>()
|
||||
const isApiAudioLoading = ref(false)
|
||||
const apiAudioSrc = ref('')
|
||||
const splitter = new GraphemeSplitter()
|
||||
function speakFromAPI(text: string) {
|
||||
|
||||
/**
|
||||
* 构建API请求URL
|
||||
* @param text 要朗读的文本
|
||||
*/
|
||||
function buildApiUrl(text: string): string | null {
|
||||
if (!settings.value.voiceAPI) {
|
||||
message.error('未设置语音API')
|
||||
return
|
||||
return null
|
||||
}
|
||||
isSpeaking.value = true
|
||||
isApiAudioLoading.value = true
|
||||
let url = `${settings.value.voiceAPISchemeType == 'https' ? 'https' : (settings.value.useAPIDirectly ? '' : FETCH_API) + 'http'}://${settings.value.voiceAPI
|
||||
.trim()
|
||||
.replace(/^(?:https?:\/\/)/, '')
|
||||
.replace(/\{\{\s*text\s*\}\}/, encodeURIComponent(text))}`
|
||||
let tempURL: URL
|
||||
const scheme =
|
||||
settings.value.voiceAPISchemeType === 'https'
|
||||
? 'https://'
|
||||
: settings.value.useAPIDirectly
|
||||
? 'http://'
|
||||
: `${FETCH_API}http://`
|
||||
|
||||
const url = `${scheme}${settings.value.voiceAPI.trim().replace(/^(?:https?:\/\/)/, '')}`.replace(
|
||||
/\{\{\s*text\s*\}\}/,
|
||||
encodeURIComponent(text),
|
||||
)
|
||||
|
||||
try {
|
||||
tempURL = new URL(url)
|
||||
const tempURL = new URL(url)
|
||||
if (isVtsuruVoiceAPI.value) {
|
||||
tempURL.searchParams.set('vtsuruId', accountInfo.value?.id.toString() ?? '-1')
|
||||
if (splitter.countGraphemes(tempURL.searchParams.get('text') ?? '') > 100) {
|
||||
message.error(`本站提供的测试接口字数不允许超过 100 字. 内容: [${tempURL.searchParams.get('text')}]`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
return tempURL.toString()
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
message.error('无效的API地址: ' + url)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从API获取语音并播放
|
||||
* @param text 要朗读的文本
|
||||
*/
|
||||
function speakFromAPI(text: string) {
|
||||
const url = buildApiUrl(text)
|
||||
if (!url) {
|
||||
cancelSpeech()
|
||||
return
|
||||
}
|
||||
if (isVtsuruVoiceAPI.value) {
|
||||
tempURL.searchParams.set('vtsuruId', accountInfo.value?.id.toString() ?? '-1')
|
||||
url = tempURL.toString()
|
||||
}
|
||||
if (isVtsuruVoiceAPI.value && splitter.countGraphemes(tempURL.searchParams.get('text') ?? '') > 50) {
|
||||
message.error('本站提供的测试接口字数不允许超过 100 字. 内容: [' + tempURL.searchParams.get('text') + ']')
|
||||
cancelSpeech()
|
||||
return
|
||||
}
|
||||
apiAudioSrc.value = url
|
||||
|
||||
speechState.isSpeaking = true
|
||||
speechState.isApiAudioLoading = true
|
||||
speechState.apiAudioSrc = url
|
||||
|
||||
nextTick(() => {
|
||||
//apiAudio.value?.load()
|
||||
apiAudio.value?.play().catch((err) => {
|
||||
if (apiAudio.value) {
|
||||
// 设置预加载
|
||||
apiAudio.value.preload = 'auto'
|
||||
|
||||
// 播放音频
|
||||
const playPromise = apiAudio.value.play()
|
||||
if (playPromise) {
|
||||
playPromise.catch((err) => {
|
||||
if (err.toString().startsWith('AbortError')) {
|
||||
return
|
||||
}
|
||||
console.log(err)
|
||||
console.log(err)
|
||||
message.error('无法播放语音:' + err)
|
||||
console.error('[speakFromAPI] 音频播放失败:', err)
|
||||
message.error('无法播放语音: ' + err.message)
|
||||
cancelSpeech()
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
function onAPIError(e: Event) {
|
||||
if (!apiAudioSrc.value) return
|
||||
if (!speechState.apiAudioSrc) return
|
||||
message.error('音频加载失败, 请检查API是否可用以及网络连接')
|
||||
cancelSpeech()
|
||||
}
|
||||
/**
|
||||
* 取消/停止当前所有朗读
|
||||
*/
|
||||
function cancelSpeech() {
|
||||
isSpeaking.value = false
|
||||
speechState.isSpeaking = false
|
||||
if (checkTimer) {
|
||||
clearInterval(checkTimer)
|
||||
checkTimer = undefined
|
||||
}
|
||||
isApiAudioLoading.value = false
|
||||
speechState.isApiAudioLoading = false
|
||||
pauseAPI()
|
||||
EasySpeech.cancel()
|
||||
speakingText.value = ''
|
||||
speechState.speakingText = ''
|
||||
}
|
||||
function pauseAPI() {
|
||||
if (!apiAudio.value?.paused) {
|
||||
apiAudio.value?.pause()
|
||||
if (apiAudio.value && !apiAudio.value.paused) {
|
||||
apiAudio.value.pause()
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 接收到事件, 添加到朗读队列
|
||||
* @param data 事件数据
|
||||
*/
|
||||
function onGetEvent(data: EventModel) {
|
||||
if (!canSpeech.value) {
|
||||
if (!speechState.canSpeech) {
|
||||
return
|
||||
}
|
||||
if (data.type == EventDataTypes.Message && (data.emoji || /^(?:\[\w+\])+$/.test(data.msg))) {
|
||||
// 不支持表情
|
||||
// 不朗读纯表情/图片弹幕
|
||||
return
|
||||
}
|
||||
if (data.type == EventDataTypes.Enter && !settings.value.enterTemplate) {
|
||||
return
|
||||
}
|
||||
if (data.type == EventDataTypes.Gift) {
|
||||
const exist = speakQueue.value.find(
|
||||
(v) =>
|
||||
v.data.type == EventDataTypes.Gift &&
|
||||
v.data.uid == data.uid &&
|
||||
v.data.msg == data.msg &&
|
||||
v.updateAt > Date.now() - (settings.value.combineGiftDelay ?? 0) * 1000,
|
||||
)
|
||||
if (exist) {
|
||||
|
||||
// 礼物合并逻辑 - 使用Map优化性能
|
||||
if (data.type == EventDataTypes.Gift && settings.value.combineGiftDelay) {
|
||||
const giftKey = `${data.uid}-${data.msg}` // 用户ID和礼物名称组成的唯一键
|
||||
const existIndex = giftCombineMap.get(giftKey)
|
||||
|
||||
if (existIndex !== undefined && existIndex < speakQueue.value.length) {
|
||||
const exist = speakQueue.value[existIndex]
|
||||
if (exist &&
|
||||
exist.data.type == EventDataTypes.Gift &&
|
||||
exist.updateAt > Date.now() - (settings.value.combineGiftDelay * 1000)) {
|
||||
// 更新现有礼物数据
|
||||
exist.updateAt = Date.now()
|
||||
exist.data.num += data.num
|
||||
exist.data.price += data.price
|
||||
exist.combineCount ??= 0
|
||||
exist.combineCount += data.num
|
||||
exist.combineCount = (exist.combineCount ?? 0) + data.num
|
||||
console.log(
|
||||
`[TTS] ${data.uname} 增加已存在礼物数量: ${data.msg} [${exist.data.num - data.num} => ${exist.data.num}]`,
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 添加新的礼物到队列
|
||||
const newIndex = speakQueue.value.length
|
||||
giftCombineMap.set(giftKey, newIndex)
|
||||
|
||||
// 清理过期的Map条目
|
||||
setTimeout(() => {
|
||||
if (giftCombineMap.get(giftKey) === newIndex) {
|
||||
giftCombineMap.delete(giftKey)
|
||||
}
|
||||
}, (settings.value.combineGiftDelay + 1) * 1000)
|
||||
}
|
||||
|
||||
speakQueue.value.push({
|
||||
data,
|
||||
updateAt: data.type == EventDataTypes.Gift ? Date.now() : 0,
|
||||
updateAt: data.type == EventDataTypes.Gift ? Date.now() : 0, // 礼物事件记录时间用于合并
|
||||
})
|
||||
}
|
||||
function getTextFromDanmaku(data: EventModel | undefined) {
|
||||
@@ -418,13 +540,13 @@ function getTextFromDanmaku(data: EventModel | undefined) {
|
||||
.replace(templateConstants.message.regex, data.msg)
|
||||
.replace(
|
||||
templateConstants.guard_level.regex,
|
||||
data.guard_level == 1 ? '总督' : data.guard_level == 2 ? '提督' : data.guard_level == 3 ? '舰长' : '',
|
||||
getGuardLevelName(data.guard_level),
|
||||
)
|
||||
.replace(templateConstants.fans_medal_level.regex, data.fans_medal_level.toString())
|
||||
.trim()
|
||||
|
||||
if (data.type === EventDataTypes.Message) {
|
||||
text = text.replace(/\[.*?\]/g, ' ') //删除表情
|
||||
text = text.replace(/\[.*?\]/g, ' ') //删除 [表情], B站的表情是 [生气了] 这样的格式
|
||||
} else if (data.type === EventDataTypes.Gift) {
|
||||
text = text.replace(templateConstants.gift_name.regex, data.msg)
|
||||
} else if (data.type === EventDataTypes.Guard) {
|
||||
@@ -432,7 +554,7 @@ function getTextFromDanmaku(data: EventModel | undefined) {
|
||||
}
|
||||
text = fullWidthToHalfWidth(text)
|
||||
.replace(/[^0-9a-zA-Z\u4E00-\u9FFF\u3400-\u4DBF\uF900-\uFAFF ,.:'"\s]/gi, '')
|
||||
.normalize('NFKC') //过滤无效字符, 全角转半角
|
||||
.normalize('NFKC')
|
||||
return text
|
||||
}
|
||||
function fullWidthToHalfWidth(str: string) {
|
||||
@@ -447,22 +569,28 @@ function fullWidthToHalfWidth(str: string) {
|
||||
return result
|
||||
}
|
||||
function startSpeech() {
|
||||
canSpeech.value = true
|
||||
speechState.canSpeech = true
|
||||
message.success('服务已启动')
|
||||
}
|
||||
function stopSpeech() {
|
||||
canSpeech.value = false
|
||||
speechState.canSpeech = false
|
||||
message.success('已停止监听')
|
||||
}
|
||||
async function uploadConfig() {
|
||||
try {
|
||||
const result = await UploadConfig('Speech', settings.value)
|
||||
if (result) {
|
||||
message.success('已保存至服务器')
|
||||
} else {
|
||||
message.error('保存失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[uploadConfig] 上传配置失败:', error)
|
||||
message.error('保存失败: ' + (error instanceof Error ? error.message : '未知错误'))
|
||||
}
|
||||
}
|
||||
async function downloadConfig() {
|
||||
try {
|
||||
const result = await DownloadConfig<SpeechSettings>('Speech')
|
||||
if (result.status === 'success' && result.data) {
|
||||
settings.value = result.data
|
||||
@@ -472,31 +600,20 @@ async function downloadConfig() {
|
||||
} else {
|
||||
message.error('获取失败: ' + result.msg)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[downloadConfig] 下载配置失败:', error)
|
||||
message.error('获取失败: ' + (error instanceof Error ? error.message : '未知错误'))
|
||||
}
|
||||
function test(type: EventDataTypes) {
|
||||
switch (type) {
|
||||
case EventDataTypes.Message:
|
||||
forceSpeak({
|
||||
type: EventDataTypes.Message,
|
||||
uname: accountInfo.value?.name ?? '测试用户',
|
||||
uid: accountInfo.value?.biliId ?? 0,
|
||||
msg: '测试弹幕',
|
||||
price: 0,
|
||||
num: 0,
|
||||
time: Date.now(),
|
||||
guard_level: 0,
|
||||
fans_medal_level: 1,
|
||||
fans_medal_name: '',
|
||||
fans_medal_wearing_status: false,
|
||||
emoji: undefined,
|
||||
uface: '',
|
||||
open_id: '00000000-0000-0000-0000-000000000000',
|
||||
ouid: '00000000-0000-0000-0000-000000000000',
|
||||
})
|
||||
break
|
||||
case EventDataTypes.Enter:
|
||||
forceSpeak({
|
||||
type: EventDataTypes.Enter,
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建测试事件数据
|
||||
* @param type 事件类型
|
||||
* @param overrides 覆盖默认值的对象
|
||||
*/
|
||||
function createTestEventData(type: EventDataTypes, overrides: Partial<EventModel>): EventModel {
|
||||
const baseData = {
|
||||
type,
|
||||
uname: accountInfo.value?.name ?? '测试用户',
|
||||
uid: accountInfo.value?.biliId ?? 0,
|
||||
msg: '',
|
||||
@@ -511,75 +628,122 @@ function test(type: EventDataTypes) {
|
||||
uface: '',
|
||||
open_id: '00000000-0000-0000-0000-000000000000',
|
||||
ouid: '00000000-0000-0000-0000-000000000000',
|
||||
})
|
||||
}
|
||||
return { ...baseData, ...overrides }
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试不同类型的事件
|
||||
* @param type 事件类型
|
||||
*/
|
||||
function test(type: EventDataTypes) {
|
||||
let testData: EventModel
|
||||
switch (type) {
|
||||
case EventDataTypes.Message:
|
||||
testData = createTestEventData(EventDataTypes.Message, { msg: '测试弹幕' })
|
||||
break
|
||||
case EventDataTypes.Enter:
|
||||
testData = createTestEventData(EventDataTypes.Enter, {})
|
||||
break
|
||||
case EventDataTypes.SC:
|
||||
forceSpeak({
|
||||
type: EventDataTypes.SC,
|
||||
uname: accountInfo.value?.name ?? '测试用户',
|
||||
uid: accountInfo.value?.biliId ?? 0,
|
||||
msg: '测试留言',
|
||||
price: 30,
|
||||
num: 1,
|
||||
time: Date.now(),
|
||||
guard_level: 0,
|
||||
fans_medal_level: 1,
|
||||
fans_medal_name: '',
|
||||
fans_medal_wearing_status: false,
|
||||
emoji: undefined,
|
||||
uface: '',
|
||||
open_id: '00000000-0000-0000-0000-000000000000',
|
||||
ouid: '00000000-0000-0000-0000-000000000000',
|
||||
})
|
||||
testData = createTestEventData(EventDataTypes.SC, { msg: '测试留言', price: 30, num: 1 })
|
||||
break
|
||||
case EventDataTypes.Guard:
|
||||
forceSpeak({
|
||||
type: EventDataTypes.Guard,
|
||||
uname: accountInfo.value?.name ?? '测试用户',
|
||||
uid: accountInfo.value?.biliId ?? 0,
|
||||
msg: '舰长',
|
||||
price: 0,
|
||||
num: 1,
|
||||
time: Date.now(),
|
||||
guard_level: 3,
|
||||
fans_medal_level: 1,
|
||||
fans_medal_name: '',
|
||||
fans_medal_wearing_status: false,
|
||||
emoji: undefined,
|
||||
uface: '',
|
||||
open_id: '00000000-0000-0000-0000-000000000000',
|
||||
ouid: '00000000-0000-0000-0000-000000000000',
|
||||
})
|
||||
testData = createTestEventData(EventDataTypes.Guard, { msg: '舰长', num: 1, guard_level: 3 })
|
||||
break
|
||||
case EventDataTypes.Gift:
|
||||
forceSpeak({
|
||||
type: EventDataTypes.Gift,
|
||||
uname: accountInfo.value?.name ?? '测试用户',
|
||||
uid: accountInfo.value?.biliId ?? 0,
|
||||
msg: '测试礼物',
|
||||
price: 5,
|
||||
num: 5,
|
||||
time: Date.now(),
|
||||
guard_level: 0,
|
||||
fans_medal_level: 1,
|
||||
fans_medal_name: '',
|
||||
fans_medal_wearing_status: false,
|
||||
emoji: undefined,
|
||||
uface: '',
|
||||
open_id: '00000000-0000-0000-0000-000000000000',
|
||||
ouid: '00000000-0000-0000-0000-000000000000',
|
||||
})
|
||||
testData = createTestEventData(EventDataTypes.Gift, { msg: '测试礼物', price: 5, num: 5 })
|
||||
break
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
// 如果正在监听,加入队列;否则直接播放
|
||||
if (speechState.canSpeech) {
|
||||
onGetEvent(testData)
|
||||
} else {
|
||||
forceSpeak(testData)
|
||||
}
|
||||
}
|
||||
|
||||
function testAPI() {
|
||||
// 直接测试API,不受监听状态影响
|
||||
speakFromAPI('这是一条测试弹幕')
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期或无效的队列项
|
||||
*/
|
||||
function cleanupQueue() {
|
||||
const now = Date.now()
|
||||
const validItems: typeof speakQueue.value = []
|
||||
|
||||
speakQueue.value.forEach((item, index) => {
|
||||
// 保留非礼物事件或未过期的礼物事件
|
||||
if (item.data.type !== EventDataTypes.Gift ||
|
||||
item.updateAt > now - (settings.value.combineGiftDelay ?? 0) * 1000) {
|
||||
validItems.push(item)
|
||||
} else {
|
||||
// 从Map中移除过期的礼物项
|
||||
const giftKey = `${item.data.uid}-${item.data.msg}`
|
||||
if (giftCombineMap.get(giftKey) === index) {
|
||||
giftCombineMap.delete(giftKey)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 如果队列过长,保留最新的项目
|
||||
if (validItems.length > MAX_QUEUE_SIZE) {
|
||||
const removedItems = validItems.splice(0, validItems.length - MAX_QUEUE_SIZE)
|
||||
console.warn(`[TTS] 队列过长,已移除 ${removedItems.length} 个旧项目`)
|
||||
}
|
||||
|
||||
speakQueue.value = validItems
|
||||
|
||||
// 重建Map索引
|
||||
giftCombineMap.clear()
|
||||
speakQueue.value.forEach((item, index) => {
|
||||
if (item.data.type === EventDataTypes.Gift) {
|
||||
const giftKey = `${item.data.uid}-${item.data.msg}`
|
||||
giftCombineMap.set(giftKey, index)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
let speechQueueTimer: number
|
||||
|
||||
onMounted(() => {
|
||||
EasySpeech.init({ maxTimeout: 5000, interval: 250 })
|
||||
speechSynthesisInfo.value = EasySpeech.detect()
|
||||
|
||||
// 自动选择默认语音
|
||||
const checkAndSetDefaultVoice = () => {
|
||||
const voices = EasySpeech.voices()
|
||||
if (voices.length > 0 && !settings.value.speechInfo.voice) {
|
||||
// 优先选择中文语音
|
||||
const chineseVoice = voices.find(v => v.lang.startsWith('zh'))
|
||||
settings.value.speechInfo.voice = chineseVoice?.name || voices[0].name
|
||||
console.log(`[TTS] 自动选择默认语音: ${settings.value.speechInfo.voice}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 立即检查一次
|
||||
checkAndSetDefaultVoice()
|
||||
|
||||
// 如果语音列表还没加载完成,等待加载完成后再检查
|
||||
if (EasySpeech.voices().length === 0) {
|
||||
const voiceCheckTimer = setInterval(() => {
|
||||
if (EasySpeech.voices().length > 0) {
|
||||
checkAndSetDefaultVoice()
|
||||
clearInterval(voiceCheckTimer)
|
||||
}
|
||||
}, 100)
|
||||
|
||||
// 10秒后停止检查避免无限循环
|
||||
setTimeout(() => clearInterval(voiceCheckTimer), 10000)
|
||||
}
|
||||
|
||||
// 语音播放队列处理
|
||||
speechQueueTimer = setInterval(() => {
|
||||
speak()
|
||||
}, 250)
|
||||
@@ -591,12 +755,29 @@ onMounted(() => {
|
||||
client.onEvent('enter', onGetEvent)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
// 清理队列定时器
|
||||
if (speechQueueTimer) {
|
||||
clearInterval(speechQueueTimer)
|
||||
}
|
||||
|
||||
// 清理语音超时定时器
|
||||
if (checkTimer) {
|
||||
clearInterval(checkTimer)
|
||||
checkTimer = undefined
|
||||
}
|
||||
|
||||
// 停止当前语音播放
|
||||
cancelSpeech()
|
||||
|
||||
// 清理事件监听器
|
||||
client.offEvent('danmaku', onGetEvent)
|
||||
client.offEvent('sc', onGetEvent)
|
||||
client.offEvent('guard', onGetEvent)
|
||||
client.offEvent('gift', onGetEvent)
|
||||
client.offEvent('enter', onGetEvent)
|
||||
|
||||
// 清理礼物合并映射
|
||||
giftCombineMap.clear()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -662,13 +843,13 @@ onUnmounted(() => {
|
||||
<br>
|
||||
<NSpace align="center">
|
||||
<NButton
|
||||
:type="canSpeech ? 'error' : 'primary'"
|
||||
:type="speechState.canSpeech ? 'error' : 'primary'"
|
||||
data-umami-event="Use TTS"
|
||||
:data-umami-event-uid="accountInfo?.id"
|
||||
size="large"
|
||||
@click="canSpeech ? stopSpeech() : startSpeech()"
|
||||
@click="speechState.canSpeech ? stopSpeech() : startSpeech()"
|
||||
>
|
||||
{{ canSpeech ? '停止监听' : '开始监听' }}
|
||||
{{ speechState.canSpeech ? '停止监听' : '开始监听' }}
|
||||
</NButton>
|
||||
<NButton
|
||||
type="primary"
|
||||
@@ -693,13 +874,13 @@ onUnmounted(() => {
|
||||
这将覆盖当前设置, 确定?
|
||||
</NPopconfirm>
|
||||
</NSpace>
|
||||
<template v-if="canSpeech">
|
||||
<template v-if="speechState.canSpeech">
|
||||
<NDivider> 状态 </NDivider>
|
||||
<NSpace
|
||||
vertical
|
||||
align="center"
|
||||
>
|
||||
<NTooltip v-if="settings.voiceType == 'api' && isApiAudioLoading">
|
||||
<NTooltip v-if="settings.voiceType == 'api' && speechState.isApiAudioLoading">
|
||||
<template #trigger>
|
||||
<NButton
|
||||
circle
|
||||
@@ -716,19 +897,19 @@ onUnmounted(() => {
|
||||
<template #trigger>
|
||||
<NButton
|
||||
circle
|
||||
:disabled="!isSpeaking"
|
||||
:style="`animation: ${isSpeaking ? 'animated-border 2.5s infinite;' : ''}`"
|
||||
:disabled="!speechState.isSpeaking"
|
||||
:style="`animation: ${speechState.isSpeaking ? 'animated-border 2.5s infinite;' : ''}`"
|
||||
@click="cancelSpeech"
|
||||
>
|
||||
<template #icon>
|
||||
<NIcon
|
||||
:component="Mic24Filled"
|
||||
:color="isSpeaking ? 'green' : 'gray'"
|
||||
:color="speechState.isSpeaking ? 'green' : 'gray'"
|
||||
/>
|
||||
</template>
|
||||
</NButton>
|
||||
</template>
|
||||
{{ isSpeaking ? '取消朗读' : '未朗读' }}
|
||||
{{ speechState.isSpeaking ? `正在朗读: ${speechState.speakingText}` : '未朗读' }}
|
||||
</NTooltip>
|
||||
<NText depth="3">
|
||||
队列: {{ speakQueue.length }}
|
||||
@@ -854,7 +1035,10 @@ onUnmounted(() => {
|
||||
<NSelect
|
||||
v-model:value="settings.speechInfo.voice"
|
||||
:options="voiceOptions"
|
||||
:fallback-option="() => ({ label: '未选择, 将使用默认语音', value: '' })"
|
||||
:fallback-option="() => ({
|
||||
label: settings.speechInfo.voice ? `已选择: ${settings.speechInfo.voice}` : '未选择, 将使用默认语音',
|
||||
value: settings.speechInfo.voice || ''
|
||||
})"
|
||||
/>
|
||||
<span style="width: 100%">
|
||||
<NText> 音量 </NText>
|
||||
@@ -976,7 +1160,7 @@ onUnmounted(() => {
|
||||
/>
|
||||
<NButton
|
||||
type="info"
|
||||
:loading="isApiAudioLoading"
|
||||
:loading="speechState.isApiAudioLoading"
|
||||
@click="testAPI"
|
||||
>
|
||||
测试
|
||||
@@ -1013,10 +1197,10 @@ onUnmounted(() => {
|
||||
</NSpace>
|
||||
<audio
|
||||
ref="apiAudio"
|
||||
:src="apiAudioSrc"
|
||||
:src="speechState.apiAudioSrc"
|
||||
:volume="settings.speechInfo.volume"
|
||||
@ended="cancelSpeech"
|
||||
@canplay="isApiAudioLoading = false"
|
||||
@canplay="speechState.isApiAudioLoading = false"
|
||||
@error="onAPIError"
|
||||
/>
|
||||
</div>
|
||||
@@ -1052,7 +1236,7 @@ onUnmounted(() => {
|
||||
/>
|
||||
<NButton
|
||||
type="info"
|
||||
:loading="isApiAudioLoading"
|
||||
:loading="speechState.isApiAudioLoading"
|
||||
@click="test(EventDataTypes.Message)"
|
||||
>
|
||||
测试
|
||||
@@ -1066,7 +1250,7 @@ onUnmounted(() => {
|
||||
/>
|
||||
<NButton
|
||||
type="info"
|
||||
:loading="isApiAudioLoading"
|
||||
:loading="speechState.isApiAudioLoading"
|
||||
@click="test(EventDataTypes.Gift)"
|
||||
>
|
||||
测试
|
||||
@@ -1080,7 +1264,7 @@ onUnmounted(() => {
|
||||
/>
|
||||
<NButton
|
||||
type="info"
|
||||
:loading="isApiAudioLoading"
|
||||
:loading="speechState.isApiAudioLoading"
|
||||
@click="test(EventDataTypes.SC)"
|
||||
>
|
||||
测试
|
||||
@@ -1094,7 +1278,7 @@ onUnmounted(() => {
|
||||
/>
|
||||
<NButton
|
||||
type="info"
|
||||
:loading="isApiAudioLoading"
|
||||
:loading="speechState.isApiAudioLoading"
|
||||
@click="test(EventDataTypes.Guard)"
|
||||
>
|
||||
测试
|
||||
@@ -1108,7 +1292,7 @@ onUnmounted(() => {
|
||||
/>
|
||||
<NButton
|
||||
type="info"
|
||||
:loading="isApiAudioLoading"
|
||||
:loading="speechState.isApiAudioLoading"
|
||||
@click="test(EventDataTypes.Enter)"
|
||||
>
|
||||
测试
|
||||
|
||||
@@ -93,7 +93,11 @@ export default defineConfig({
|
||||
],
|
||||
server: { port: 51000 },
|
||||
resolve: { alias: { '@': path.resolve(__dirname, 'src') } },
|
||||
define: { 'process.env': {}, global: 'window' },
|
||||
define: {
|
||||
'process.env': {},
|
||||
global: 'window',
|
||||
__BUILD_TIME__: JSON.stringify(new Date().toISOString())
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ['@vicons/fluent', '@vicons/ionicons5', 'vue', 'vue-router']
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user