feat: 更新配置和组件以支持构建时间显示功能, 修复读弹幕的问题

- 在vite.config.mts中添加__BUILD_TIME__常量以记录构建时间
- 在AboutView.vue中显示构建时间及其相对时间
- 在ReadDanmaku.vue中重构语音合成状态管理,优化礼物合并逻辑
- 更新相关类型定义,增强代码可读性和可维护性
This commit is contained in:
Megghy
2025-06-25 10:55:27 +08:00
parent a8b4e2fbad
commit f57c856c3b
5 changed files with 411 additions and 202 deletions

View File

@@ -553,6 +553,6 @@ declare global {
// for type re-export // for type re-export
declare global { declare global {
// @ts-ignore // @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') import('vue')
} }

View File

@@ -1 +1 @@
declare const __BUILD_TIME__: string;

View File

@@ -1,7 +1,25 @@
<script setup lang="ts"> <script setup lang="ts">
import { NButton, NCard, NDivider, NLayoutContent, NSpace, NText, NTimeline, NTimelineItem, NTag, NIcon } from 'naive-ui' 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 { 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> </script>
<template> <template>
<NLayoutContent style="height: 100vh; padding: 20px 0;"> <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 style="font-size: 22px; font-weight: bold; padding: 8px 0;">
关于 关于
</div> </div>
<div style="font-size: 13px; color: #666; margin-top: 4px; padding-bottom: 8px;">
构建时间: {{ buildTime.date }} ({{ buildTime.relative }})
</div>
</template> </template>
<NText> <NText>
一个兴趣使然的网站 一个兴趣使然的网站

View File

@@ -38,7 +38,7 @@ import {
NUl, NUl,
useMessage, useMessage,
} from 'naive-ui' } 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 { useRoute } from 'vue-router'
import { clearInterval, setInterval } from 'worker-timers' import { clearInterval, setInterval } from 'worker-timers'
@@ -57,12 +57,12 @@ type SpeechSettings = {
enterTemplate: string enterTemplate: string
voiceType: 'local' | 'api' voiceType: 'local' | 'api'
voiceAPISchemeType: 'http' | 'https' voiceAPISchemeType: 'http' | 'https'
voiceAPI?: string voiceAPI: string
splitText: boolean splitText: boolean
useAPIDirectly: boolean useAPIDirectly: boolean
combineGiftDelay: number | undefined
combineGiftDelay?: number
} }
type SpeechInfo = { type SpeechInfo = {
volume: number volume: number
pitch: number pitch: number
@@ -70,6 +70,22 @@ type SpeechInfo = {
voice: string 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 accountInfo = useAccount()
const message = useMessage() const message = useMessage()
const route = useRoute() 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', voiceAPI: 'voice.vtsuru.live/voice/bert-vits2?text={{text}}&id=1&format=mp3&streaming=true',
useAPIDirectly: false, useAPIDirectly: false,
splitText: false, splitText: false,
combineGiftDelay: 2, combineGiftDelay: 2,
}) })
const speechSynthesisInfo = ref<{ const speechSynthesisInfo = ref<{
@@ -121,16 +136,15 @@ const voiceOptions = computed(() => {
.DistinctBy((v) => v.value) .DistinctBy((v) => v.value)
.ToArray() .ToArray()
}) })
const isSpeaking = ref(false)
const speakingText = ref('')
const speakQueue = ref<{ updateAt: number; combineCount?: number; data: EventModel }[]>([]) const speakQueue = ref<{ updateAt: number; combineCount?: number; data: EventModel }[]>([])
const giftCombineMap = new Map<string, number>() // 用于快速查找礼物合并项
const MAX_QUEUE_SIZE = 50 // 最大队列长度限制
const isVtsuruVoiceAPI = computed(() => { const isVtsuruVoiceAPI = computed(() => {
return ( return (
settings.value.voiceType == 'api' && settings.value.voiceAPI?.toLowerCase().trim().startsWith('voice.vtsuru.live') settings.value.voiceType == 'api' && settings.value.voiceAPI?.toLowerCase().trim().startsWith('voice.vtsuru.live')
) )
}) })
const canSpeech = ref(false)
const readedDanmaku = ref(0) const readedDanmaku = ref(0)
const templateConstants = { const templateConstants = {
@@ -175,6 +189,22 @@ const templateConstants = {
regex: /\{\s*gift_name\s*\}/gi, 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) { function forceSpeak(data: EventModel) {
cancelSpeech() cancelSpeech()
@@ -188,21 +218,55 @@ function forceSpeak(data: EventModel) {
}) })
} }
async function speak() { async function speak() {
if (isSpeaking.value || speakQueue.value.length == 0) { if (speechState.isSpeaking || speakQueue.value.length == 0) {
return return
} }
const data = speakQueue.value[0]
if ( // 寻找可以立即播放的事件(优先非等待合并的礼物)
data.data.type == EventDataTypes.Gift && let targetIndex = -1
data.updateAt > Date.now() - (settings.value.combineGiftDelay ?? 0) * 1000 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 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) { if (text) {
isSpeaking.value = true speechState.isSpeaking = true
readedDanmaku.value++ readedDanmaku.value++
speakingText.value = text speechState.speakingText = text
if (checkTimer) { if (checkTimer) {
clearInterval(checkTimer) clearInterval(checkTimer)
} }
@@ -216,7 +280,7 @@ async function speak() {
text = settings.value.splitText ? insertSpaces(text) : text text = settings.value.splitText ? insertSpaces(text) : text
speakFromAPI(text) speakFromAPI(text)
} }
console.log(`[TTS] 正在朗读: ${text}`) console.log(`[TTS] 正在朗读: ${text} ${targetIndex > 0 ? '(跳过等待合并的礼物)' : ''}`)
} }
} }
function insertSpaces(sentence: string) { function insertSpaces(sentence: string) {
@@ -234,6 +298,10 @@ function insertSpaces(sentence: string) {
return sentence return sentence
} }
let checkTimer: number | undefined let checkTimer: number | undefined
/**
* 使用浏览器本地语音合成功能朗读
* @param text 要朗读的文本
*/
function speakDirect(text: string) { function speakDirect(text: string) {
try { try {
const synth = window.speechSynthesis const synth = window.speechSynthesis
@@ -266,109 +334,163 @@ function speakDirect(text: string) {
} }
} catch (err) { } catch (err) {
console.log(err) console.log(err)
// 如果本地语音合成失败,确保清理定时器
cancelSpeech()
} }
} }
const apiAudio = ref<HTMLAudioElement>() const apiAudio = ref<HTMLAudioElement>()
const isApiAudioLoading = ref(false)
const apiAudioSrc = ref('')
const splitter = new GraphemeSplitter() const splitter = new GraphemeSplitter()
function speakFromAPI(text: string) {
/**
* 构建API请求URL
* @param text 要朗读的文本
*/
function buildApiUrl(text: string): string | null {
if (!settings.value.voiceAPI) { if (!settings.value.voiceAPI) {
message.error('未设置语音API') message.error('未设置语音API')
return return null
} }
isSpeaking.value = true const scheme =
isApiAudioLoading.value = true settings.value.voiceAPISchemeType === 'https'
let url = `${settings.value.voiceAPISchemeType == 'https' ? 'https' : (settings.value.useAPIDirectly ? '' : FETCH_API) + 'http'}://${settings.value.voiceAPI ? 'https://'
.trim() : settings.value.useAPIDirectly
.replace(/^(?:https?:\/\/)/, '') ? 'http://'
.replace(/\{\{\s*text\s*\}\}/, encodeURIComponent(text))}` : `${FETCH_API}http://`
let tempURL: URL
const url = `${scheme}${settings.value.voiceAPI.trim().replace(/^(?:https?:\/\/)/, '')}`.replace(
/\{\{\s*text\s*\}\}/,
encodeURIComponent(text),
)
try { 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) { } catch (err) {
console.log(err) console.log(err)
message.error('无效的API地址: ' + url) message.error('无效的API地址: ' + url)
return null
}
}
/**
* 从API获取语音并播放
* @param text 要朗读的文本
*/
function speakFromAPI(text: string) {
const url = buildApiUrl(text)
if (!url) {
cancelSpeech() cancelSpeech()
return return
} }
if (isVtsuruVoiceAPI.value) {
tempURL.searchParams.set('vtsuruId', accountInfo.value?.id.toString() ?? '-1') speechState.isSpeaking = true
url = tempURL.toString() speechState.isApiAudioLoading = true
} speechState.apiAudioSrc = url
if (isVtsuruVoiceAPI.value && splitter.countGraphemes(tempURL.searchParams.get('text') ?? '') > 50) {
message.error('本站提供的测试接口字数不允许超过 100 字. 内容: [' + tempURL.searchParams.get('text') + ']')
cancelSpeech()
return
}
apiAudioSrc.value = url
nextTick(() => { nextTick(() => {
//apiAudio.value?.load() if (apiAudio.value) {
apiAudio.value?.play().catch((err) => { // 设置预加载
apiAudio.value.preload = 'auto'
// 播放音频
const playPromise = apiAudio.value.play()
if (playPromise) {
playPromise.catch((err) => {
if (err.toString().startsWith('AbortError')) { if (err.toString().startsWith('AbortError')) {
return return
} }
console.log(err) console.error('[speakFromAPI] 音频播放失败:', err)
console.log(err) message.error('无法播放语音: ' + err.message)
message.error('无法播放语音:' + err)
cancelSpeech() cancelSpeech()
}) })
}
}
}) })
} }
function onAPIError(e: Event) { function onAPIError(e: Event) {
if (!apiAudioSrc.value) return if (!speechState.apiAudioSrc) return
message.error('音频加载失败, 请检查API是否可用以及网络连接')
cancelSpeech() cancelSpeech()
} }
/**
* 取消/停止当前所有朗读
*/
function cancelSpeech() { function cancelSpeech() {
isSpeaking.value = false speechState.isSpeaking = false
if (checkTimer) { if (checkTimer) {
clearInterval(checkTimer) clearInterval(checkTimer)
checkTimer = undefined checkTimer = undefined
} }
isApiAudioLoading.value = false speechState.isApiAudioLoading = false
pauseAPI() pauseAPI()
EasySpeech.cancel() EasySpeech.cancel()
speakingText.value = '' speechState.speakingText = ''
} }
function pauseAPI() { function pauseAPI() {
if (!apiAudio.value?.paused) { if (apiAudio.value && !apiAudio.value.paused) {
apiAudio.value?.pause() apiAudio.value.pause()
} }
} }
/**
* 接收到事件, 添加到朗读队列
* @param data 事件数据
*/
function onGetEvent(data: EventModel) { function onGetEvent(data: EventModel) {
if (!canSpeech.value) { if (!speechState.canSpeech) {
return return
} }
if (data.type == EventDataTypes.Message && (data.emoji || /^(?:\[\w+\])+$/.test(data.msg))) { if (data.type == EventDataTypes.Message && (data.emoji || /^(?:\[\w+\])+$/.test(data.msg))) {
// 不支持表情 // 不朗读纯表情/图片弹幕
return return
} }
if (data.type == EventDataTypes.Enter && !settings.value.enterTemplate) { if (data.type == EventDataTypes.Enter && !settings.value.enterTemplate) {
return return
} }
if (data.type == EventDataTypes.Gift) {
const exist = speakQueue.value.find( // 礼物合并逻辑 - 使用Map优化性能
(v) => if (data.type == EventDataTypes.Gift && settings.value.combineGiftDelay) {
v.data.type == EventDataTypes.Gift && const giftKey = `${data.uid}-${data.msg}` // 用户ID和礼物名称组成的唯一键
v.data.uid == data.uid && const existIndex = giftCombineMap.get(giftKey)
v.data.msg == data.msg &&
v.updateAt > Date.now() - (settings.value.combineGiftDelay ?? 0) * 1000, if (existIndex !== undefined && existIndex < speakQueue.value.length) {
) const exist = speakQueue.value[existIndex]
if (exist) { if (exist &&
exist.data.type == EventDataTypes.Gift &&
exist.updateAt > Date.now() - (settings.value.combineGiftDelay * 1000)) {
// 更新现有礼物数据
exist.updateAt = Date.now() exist.updateAt = Date.now()
exist.data.num += data.num exist.data.num += data.num
exist.data.price += data.price exist.data.price += data.price
exist.combineCount ??= 0 exist.combineCount = (exist.combineCount ?? 0) + data.num
exist.combineCount += data.num
console.log( console.log(
`[TTS] ${data.uname} 增加已存在礼物数量: ${data.msg} [${exist.data.num - data.num} => ${exist.data.num}]`, `[TTS] ${data.uname} 增加已存在礼物数量: ${data.msg} [${exist.data.num - data.num} => ${exist.data.num}]`,
) )
return 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({ speakQueue.value.push({
data, data,
updateAt: data.type == EventDataTypes.Gift ? Date.now() : 0, updateAt: data.type == EventDataTypes.Gift ? Date.now() : 0, // 礼物事件记录时间用于合并
}) })
} }
function getTextFromDanmaku(data: EventModel | undefined) { function getTextFromDanmaku(data: EventModel | undefined) {
@@ -418,13 +540,13 @@ function getTextFromDanmaku(data: EventModel | undefined) {
.replace(templateConstants.message.regex, data.msg) .replace(templateConstants.message.regex, data.msg)
.replace( .replace(
templateConstants.guard_level.regex, 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()) .replace(templateConstants.fans_medal_level.regex, data.fans_medal_level.toString())
.trim() .trim()
if (data.type === EventDataTypes.Message) { if (data.type === EventDataTypes.Message) {
text = text.replace(/\[.*?\]/g, ' ') //删除表情 text = text.replace(/\[.*?\]/g, ' ') //删除 [表情], B站的表情是 [生气了] 这样的格式
} else if (data.type === EventDataTypes.Gift) { } else if (data.type === EventDataTypes.Gift) {
text = text.replace(templateConstants.gift_name.regex, data.msg) text = text.replace(templateConstants.gift_name.regex, data.msg)
} else if (data.type === EventDataTypes.Guard) { } else if (data.type === EventDataTypes.Guard) {
@@ -432,7 +554,7 @@ function getTextFromDanmaku(data: EventModel | undefined) {
} }
text = fullWidthToHalfWidth(text) text = fullWidthToHalfWidth(text)
.replace(/[^0-9a-zA-Z\u4E00-\u9FFF\u3400-\u4DBF\uF900-\uFAFF ,.:'"\s]/gi, '') .replace(/[^0-9a-zA-Z\u4E00-\u9FFF\u3400-\u4DBF\uF900-\uFAFF ,.:'"\s]/gi, '')
.normalize('NFKC') //过滤无效字符, 全角转半角 .normalize('NFKC')
return text return text
} }
function fullWidthToHalfWidth(str: string) { function fullWidthToHalfWidth(str: string) {
@@ -447,22 +569,28 @@ function fullWidthToHalfWidth(str: string) {
return result return result
} }
function startSpeech() { function startSpeech() {
canSpeech.value = true speechState.canSpeech = true
message.success('服务已启动') message.success('服务已启动')
} }
function stopSpeech() { function stopSpeech() {
canSpeech.value = false speechState.canSpeech = false
message.success('已停止监听') message.success('已停止监听')
} }
async function uploadConfig() { async function uploadConfig() {
try {
const result = await UploadConfig('Speech', settings.value) const result = await UploadConfig('Speech', settings.value)
if (result) { if (result) {
message.success('已保存至服务器') message.success('已保存至服务器')
} else { } else {
message.error('保存失败') message.error('保存失败')
} }
} catch (error) {
console.error('[uploadConfig] 上传配置失败:', error)
message.error('保存失败: ' + (error instanceof Error ? error.message : '未知错误'))
}
} }
async function downloadConfig() { async function downloadConfig() {
try {
const result = await DownloadConfig<SpeechSettings>('Speech') const result = await DownloadConfig<SpeechSettings>('Speech')
if (result.status === 'success' && result.data) { if (result.status === 'success' && result.data) {
settings.value = result.data settings.value = result.data
@@ -472,31 +600,20 @@ async function downloadConfig() {
} else { } else {
message.error('获取失败: ' + result.msg) 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, * @param type 事件类型
uname: accountInfo.value?.name ?? '测试用户', * @param overrides 覆盖默认值的对象
uid: accountInfo.value?.biliId ?? 0, */
msg: '测试弹幕', function createTestEventData(type: EventDataTypes, overrides: Partial<EventModel>): EventModel {
price: 0, const baseData = {
num: 0, type,
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,
uname: accountInfo.value?.name ?? '测试用户', uname: accountInfo.value?.name ?? '测试用户',
uid: accountInfo.value?.biliId ?? 0, uid: accountInfo.value?.biliId ?? 0,
msg: '', msg: '',
@@ -511,75 +628,122 @@ function test(type: EventDataTypes) {
uface: '', uface: '',
open_id: '00000000-0000-0000-0000-000000000000', open_id: '00000000-0000-0000-0000-000000000000',
ouid: '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 break
case EventDataTypes.SC: case EventDataTypes.SC:
forceSpeak({ testData = createTestEventData(EventDataTypes.SC, { msg: '测试留言', price: 30, num: 1 })
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',
})
break break
case EventDataTypes.Guard: case EventDataTypes.Guard:
forceSpeak({ testData = createTestEventData(EventDataTypes.Guard, { msg: '舰长', num: 1, guard_level: 3 })
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',
})
break break
case EventDataTypes.Gift: case EventDataTypes.Gift:
forceSpeak({ testData = createTestEventData(EventDataTypes.Gift, { msg: '测试礼物', price: 5, num: 5 })
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',
})
break break
default:
return
}
// 如果正在监听,加入队列;否则直接播放
if (speechState.canSpeech) {
onGetEvent(testData)
} else {
forceSpeak(testData)
} }
} }
function testAPI() { function testAPI() {
// 直接测试API不受监听状态影响
speakFromAPI('这是一条测试弹幕') 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 let speechQueueTimer: number
onMounted(() => { onMounted(() => {
EasySpeech.init({ maxTimeout: 5000, interval: 250 }) EasySpeech.init({ maxTimeout: 5000, interval: 250 })
speechSynthesisInfo.value = EasySpeech.detect() 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(() => { speechQueueTimer = setInterval(() => {
speak() speak()
}, 250) }, 250)
@@ -591,12 +755,29 @@ onMounted(() => {
client.onEvent('enter', onGetEvent) client.onEvent('enter', onGetEvent)
}) })
onUnmounted(() => { onUnmounted(() => {
// 清理队列定时器
if (speechQueueTimer) {
clearInterval(speechQueueTimer) clearInterval(speechQueueTimer)
}
// 清理语音超时定时器
if (checkTimer) {
clearInterval(checkTimer)
checkTimer = undefined
}
// 停止当前语音播放
cancelSpeech()
// 清理事件监听器
client.offEvent('danmaku', onGetEvent) client.offEvent('danmaku', onGetEvent)
client.offEvent('sc', onGetEvent) client.offEvent('sc', onGetEvent)
client.offEvent('guard', onGetEvent) client.offEvent('guard', onGetEvent)
client.offEvent('gift', onGetEvent) client.offEvent('gift', onGetEvent)
client.offEvent('enter', onGetEvent) client.offEvent('enter', onGetEvent)
// 清理礼物合并映射
giftCombineMap.clear()
}) })
</script> </script>
@@ -662,13 +843,13 @@ onUnmounted(() => {
<br> <br>
<NSpace align="center"> <NSpace align="center">
<NButton <NButton
:type="canSpeech ? 'error' : 'primary'" :type="speechState.canSpeech ? 'error' : 'primary'"
data-umami-event="Use TTS" data-umami-event="Use TTS"
:data-umami-event-uid="accountInfo?.id" :data-umami-event-uid="accountInfo?.id"
size="large" size="large"
@click="canSpeech ? stopSpeech() : startSpeech()" @click="speechState.canSpeech ? stopSpeech() : startSpeech()"
> >
{{ canSpeech ? '停止监听' : '开始监听' }} {{ speechState.canSpeech ? '停止监听' : '开始监听' }}
</NButton> </NButton>
<NButton <NButton
type="primary" type="primary"
@@ -693,13 +874,13 @@ onUnmounted(() => {
这将覆盖当前设置, 确定? 这将覆盖当前设置, 确定?
</NPopconfirm> </NPopconfirm>
</NSpace> </NSpace>
<template v-if="canSpeech"> <template v-if="speechState.canSpeech">
<NDivider> 状态 </NDivider> <NDivider> 状态 </NDivider>
<NSpace <NSpace
vertical vertical
align="center" align="center"
> >
<NTooltip v-if="settings.voiceType == 'api' && isApiAudioLoading"> <NTooltip v-if="settings.voiceType == 'api' && speechState.isApiAudioLoading">
<template #trigger> <template #trigger>
<NButton <NButton
circle circle
@@ -716,19 +897,19 @@ onUnmounted(() => {
<template #trigger> <template #trigger>
<NButton <NButton
circle circle
:disabled="!isSpeaking" :disabled="!speechState.isSpeaking"
:style="`animation: ${isSpeaking ? 'animated-border 2.5s infinite;' : ''}`" :style="`animation: ${speechState.isSpeaking ? 'animated-border 2.5s infinite;' : ''}`"
@click="cancelSpeech" @click="cancelSpeech"
> >
<template #icon> <template #icon>
<NIcon <NIcon
:component="Mic24Filled" :component="Mic24Filled"
:color="isSpeaking ? 'green' : 'gray'" :color="speechState.isSpeaking ? 'green' : 'gray'"
/> />
</template> </template>
</NButton> </NButton>
</template> </template>
{{ isSpeaking ? '取消朗读' : '未朗读' }} {{ speechState.isSpeaking ? `正在朗读: ${speechState.speakingText}` : '未朗读' }}
</NTooltip> </NTooltip>
<NText depth="3"> <NText depth="3">
队列: {{ speakQueue.length }} 队列: {{ speakQueue.length }}
@@ -854,7 +1035,10 @@ onUnmounted(() => {
<NSelect <NSelect
v-model:value="settings.speechInfo.voice" v-model:value="settings.speechInfo.voice"
:options="voiceOptions" :options="voiceOptions"
:fallback-option="() => ({ label: '未选择, 将使用默认语音', value: '' })" :fallback-option="() => ({
label: settings.speechInfo.voice ? `已选择: ${settings.speechInfo.voice}` : '未选择, 将使用默认语音',
value: settings.speechInfo.voice || ''
})"
/> />
<span style="width: 100%"> <span style="width: 100%">
<NText> 音量 </NText> <NText> 音量 </NText>
@@ -976,7 +1160,7 @@ onUnmounted(() => {
/> />
<NButton <NButton
type="info" type="info"
:loading="isApiAudioLoading" :loading="speechState.isApiAudioLoading"
@click="testAPI" @click="testAPI"
> >
测试 测试
@@ -1013,10 +1197,10 @@ onUnmounted(() => {
</NSpace> </NSpace>
<audio <audio
ref="apiAudio" ref="apiAudio"
:src="apiAudioSrc" :src="speechState.apiAudioSrc"
:volume="settings.speechInfo.volume" :volume="settings.speechInfo.volume"
@ended="cancelSpeech" @ended="cancelSpeech"
@canplay="isApiAudioLoading = false" @canplay="speechState.isApiAudioLoading = false"
@error="onAPIError" @error="onAPIError"
/> />
</div> </div>
@@ -1052,7 +1236,7 @@ onUnmounted(() => {
/> />
<NButton <NButton
type="info" type="info"
:loading="isApiAudioLoading" :loading="speechState.isApiAudioLoading"
@click="test(EventDataTypes.Message)" @click="test(EventDataTypes.Message)"
> >
测试 测试
@@ -1066,7 +1250,7 @@ onUnmounted(() => {
/> />
<NButton <NButton
type="info" type="info"
:loading="isApiAudioLoading" :loading="speechState.isApiAudioLoading"
@click="test(EventDataTypes.Gift)" @click="test(EventDataTypes.Gift)"
> >
测试 测试
@@ -1080,7 +1264,7 @@ onUnmounted(() => {
/> />
<NButton <NButton
type="info" type="info"
:loading="isApiAudioLoading" :loading="speechState.isApiAudioLoading"
@click="test(EventDataTypes.SC)" @click="test(EventDataTypes.SC)"
> >
测试 测试
@@ -1094,7 +1278,7 @@ onUnmounted(() => {
/> />
<NButton <NButton
type="info" type="info"
:loading="isApiAudioLoading" :loading="speechState.isApiAudioLoading"
@click="test(EventDataTypes.Guard)" @click="test(EventDataTypes.Guard)"
> >
测试 测试
@@ -1108,7 +1292,7 @@ onUnmounted(() => {
/> />
<NButton <NButton
type="info" type="info"
:loading="isApiAudioLoading" :loading="speechState.isApiAudioLoading"
@click="test(EventDataTypes.Enter)" @click="test(EventDataTypes.Enter)"
> >
测试 测试

View File

@@ -93,7 +93,11 @@ export default defineConfig({
], ],
server: { port: 51000 }, server: { port: 51000 },
resolve: { alias: { '@': path.resolve(__dirname, 'src') } }, resolve: { alias: { '@': path.resolve(__dirname, 'src') } },
define: { 'process.env': {}, global: 'window' }, define: {
'process.env': {},
global: 'window',
__BUILD_TIME__: JSON.stringify(new Date().toISOString())
},
optimizeDeps: { optimizeDeps: {
include: ['@vicons/fluent', '@vicons/ionicons5', 'vue', 'vue-router'] include: ['@vicons/fluent', '@vicons/ionicons5', 'vue', 'vue-router']
}, },