mirror of
https://github.com/Megghy/vtsuru.live.git
synced 2025-12-06 18:36:55 +08:00
support use tts from api
This commit is contained in:
@@ -4,10 +4,11 @@ import { useAccount } from '@/api/account'
|
|||||||
import { EventDataTypes, EventModel } from '@/api/api-models'
|
import { EventDataTypes, EventModel } from '@/api/api-models'
|
||||||
import { QueryGetAPI, QueryPostAPI } from '@/api/query'
|
import { QueryGetAPI, QueryPostAPI } from '@/api/query'
|
||||||
import DanmakuClient, { RoomAuthInfo } from '@/data/DanmakuClient'
|
import DanmakuClient, { RoomAuthInfo } from '@/data/DanmakuClient'
|
||||||
import { VTSURU_API_URL } from '@/data/constants'
|
import { FETCH_API, VTSURU_API_URL } from '@/data/constants'
|
||||||
import { Info24Filled, Mic24Filled } from '@vicons/fluent'
|
import { Info24Filled, Mic24Filled } from '@vicons/fluent'
|
||||||
import { useStorage } from '@vueuse/core'
|
import { useStorage } from '@vueuse/core'
|
||||||
import EasySpeech from 'easy-speech'
|
import EasySpeech from 'easy-speech'
|
||||||
|
import GraphemeSplitter from 'grapheme-splitter'
|
||||||
import { List } from 'linqts'
|
import { List } from 'linqts'
|
||||||
import {
|
import {
|
||||||
NAlert,
|
NAlert,
|
||||||
@@ -22,18 +23,23 @@ import {
|
|||||||
NInputGroup,
|
NInputGroup,
|
||||||
NInputGroupLabel,
|
NInputGroupLabel,
|
||||||
NInputNumber,
|
NInputNumber,
|
||||||
|
NLi,
|
||||||
NList,
|
NList,
|
||||||
NListItem,
|
NListItem,
|
||||||
NPopconfirm,
|
NPopconfirm,
|
||||||
|
NRadioButton,
|
||||||
|
NRadioGroup,
|
||||||
NSelect,
|
NSelect,
|
||||||
NSlider,
|
NSlider,
|
||||||
NSpace,
|
NSpace,
|
||||||
|
NSpin,
|
||||||
NTag,
|
NTag,
|
||||||
NText,
|
NText,
|
||||||
NTooltip,
|
NTooltip,
|
||||||
|
NUl,
|
||||||
useMessage,
|
useMessage,
|
||||||
} from 'naive-ui'
|
} from 'naive-ui'
|
||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, nextTick, onMounted, onUnmounted, 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'
|
||||||
|
|
||||||
@@ -50,6 +56,9 @@ type SpeechSettings = {
|
|||||||
scTemplate: string
|
scTemplate: string
|
||||||
guardTemplate: string
|
guardTemplate: string
|
||||||
giftTemplate: string
|
giftTemplate: string
|
||||||
|
voiceType: 'local' | 'api'
|
||||||
|
voiceAPISchemeType: 'http' | 'https'
|
||||||
|
voiceAPI?: string
|
||||||
|
|
||||||
combineGiftDelay?: number
|
combineGiftDelay?: number
|
||||||
}
|
}
|
||||||
@@ -74,6 +83,9 @@ const settings = useStorage<SpeechSettings>('Setting.Speech', {
|
|||||||
scTemplate: '{name} 发送了醒目留言: {message}',
|
scTemplate: '{name} 发送了醒目留言: {message}',
|
||||||
guardTemplate: '感谢 {name} 的 {count} 个月 {guard_level}',
|
guardTemplate: '感谢 {name} 的 {count} 个月 {guard_level}',
|
||||||
giftTemplate: '感谢 {name} 赠送的 {count} 个 {gift_name}',
|
giftTemplate: '感谢 {name} 赠送的 {count} 个 {gift_name}',
|
||||||
|
voiceType: 'local',
|
||||||
|
voiceAPISchemeType: 'https',
|
||||||
|
voiceAPI: 'voice.vtsuru.live/voice/bert-vits2?text={{text}}&id=1',
|
||||||
|
|
||||||
combineGiftDelay: 2,
|
combineGiftDelay: 2,
|
||||||
})
|
})
|
||||||
@@ -106,6 +118,9 @@ const voiceOptions = computed(() => {
|
|||||||
})
|
})
|
||||||
const isSpeaking = ref(false)
|
const isSpeaking = ref(false)
|
||||||
const speakQueue = ref<{ updateAt: number; combineCount?: number; data: EventModel }[]>([])
|
const speakQueue = ref<{ updateAt: number; combineCount?: number; data: EventModel }[]>([])
|
||||||
|
const isVtsuruVoiceAPI = computed(() => {
|
||||||
|
return settings.value.voiceType == 'api' && settings.value.voiceAPI?.toLowerCase().trim().startsWith('voice.vtsuru.live')
|
||||||
|
})
|
||||||
|
|
||||||
const canSpeech = ref(false)
|
const canSpeech = ref(false)
|
||||||
const readedDanmaku = ref(0)
|
const readedDanmaku = ref(0)
|
||||||
@@ -177,29 +192,21 @@ async function speak() {
|
|||||||
isSpeaking.value = true
|
isSpeaking.value = true
|
||||||
readedDanmaku.value++
|
readedDanmaku.value++
|
||||||
console.log(`[TTS] 正在朗读: ${text}`)
|
console.log(`[TTS] 正在朗读: ${text}`)
|
||||||
/*await EasySpeech.speak({
|
if (checkTimer) {
|
||||||
text: text,
|
clearInterval(checkTimer)
|
||||||
volume: settings.value.speechInfo.volume,
|
}
|
||||||
pitch: settings.value.speechInfo.pitch,
|
checkTimer = setInterval(() => {
|
||||||
rate: settings.value.speechInfo.rate,
|
message.error('语音播放超时')
|
||||||
voice: EasySpeech.voices().find((v) => v.name == settings.value.speechInfo.voice) ?? undefined,
|
cancelSpeech()
|
||||||
})
|
}, 30000)
|
||||||
.then(() => {})
|
if (settings.value.voiceType == 'local') {
|
||||||
.catch((error) => {
|
speakDirect(text)
|
||||||
if (error.error == 'interrupted') {
|
} else {
|
||||||
//被中断
|
speakFromAPI(text)
|
||||||
return
|
}
|
||||||
}
|
|
||||||
console.log(error)
|
|
||||||
message.error('无法播放语音: ' + error.error)
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
isSpeaking.value = false
|
|
||||||
})*/
|
|
||||||
speakDirect(text)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let checkTimer: number
|
let checkTimer: number | undefined
|
||||||
function speakDirect(text: string) {
|
function speakDirect(text: string) {
|
||||||
try {
|
try {
|
||||||
const synth = window.speechSynthesis
|
const synth = window.speechSynthesis
|
||||||
@@ -213,18 +220,11 @@ function speakDirect(text: string) {
|
|||||||
let voices = synth.getVoices()
|
let voices = synth.getVoices()
|
||||||
const voice = voices.find((v) => v.name === settings.value.speechInfo.voice)
|
const voice = voices.find((v) => v.name === settings.value.speechInfo.voice)
|
||||||
if (voice) {
|
if (voice) {
|
||||||
if (checkTimer) {
|
|
||||||
clearInterval(checkTimer)
|
|
||||||
}
|
|
||||||
u.voice = voice
|
u.voice = voice
|
||||||
u.volume = settings.value.speechInfo.volume
|
u.volume = settings.value.speechInfo.volume
|
||||||
u.rate = settings.value.speechInfo.rate
|
u.rate = settings.value.speechInfo.rate
|
||||||
u.pitch = settings.value.speechInfo.pitch
|
u.pitch = settings.value.speechInfo.pitch
|
||||||
synth.speak(u)
|
synth.speak(u)
|
||||||
checkTimer = setInterval(() => {
|
|
||||||
message.error('语音播放超时')
|
|
||||||
cancelSpeech()
|
|
||||||
}, 30000)
|
|
||||||
u.onend = () => {
|
u.onend = () => {
|
||||||
cancelSpeech()
|
cancelSpeech()
|
||||||
}
|
}
|
||||||
@@ -241,6 +241,51 @@ function speakDirect(text: string) {
|
|||||||
console.log(err)
|
console.log(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const apiAudio = ref<HTMLAudioElement>()
|
||||||
|
const isApiAudioLoading = ref(false)
|
||||||
|
const apiAudioSrc = ref('')
|
||||||
|
const splitter = new GraphemeSplitter()
|
||||||
|
function speakFromAPI(text: string) {
|
||||||
|
if (!settings.value.voiceAPI) {
|
||||||
|
message.error('未设置语音API')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isSpeaking.value = true
|
||||||
|
isApiAudioLoading.value = true
|
||||||
|
const url = `${settings.value.voiceAPISchemeType == 'https' ? 'https' : FETCH_API + 'http'}://${settings.value.voiceAPI
|
||||||
|
.trim()
|
||||||
|
.replace(/^(?:https?:\/\/)/, '')
|
||||||
|
.replace(/\{\{\s*text\s*\}\}/, encodeURIComponent(text))}`
|
||||||
|
const tempURL = new URL(url)
|
||||||
|
if (isVtsuruVoiceAPI.value && splitter.countGraphemes(tempURL.searchParams.get('text') ?? '') > 50) {
|
||||||
|
message.error('本站提供的测试接口字数不允许超过 50 字. 内容: [' + tempURL.searchParams.get('text') + ']')
|
||||||
|
cancelSpeech()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
apiAudioSrc.value = url
|
||||||
|
nextTick(() => {
|
||||||
|
//apiAudio.value?.load()
|
||||||
|
apiAudio.value?.play().catch((err) => {
|
||||||
|
console.log(err)
|
||||||
|
message.error('无法播放语音:' + err)
|
||||||
|
cancelSpeech()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
function onAPIError(e: Event) {
|
||||||
|
if (!apiAudioSrc.value) return
|
||||||
|
cancelSpeech()
|
||||||
|
}
|
||||||
|
function cancelSpeech() {
|
||||||
|
isSpeaking.value = false
|
||||||
|
if (checkTimer) {
|
||||||
|
clearInterval(checkTimer)
|
||||||
|
checkTimer = undefined
|
||||||
|
}
|
||||||
|
isApiAudioLoading.value = false
|
||||||
|
apiAudio.value?.pause()
|
||||||
|
EasySpeech.cancel()
|
||||||
|
}
|
||||||
function onGetEvent(data: EventModel) {
|
function onGetEvent(data: EventModel) {
|
||||||
if (!canSpeech.value) {
|
if (!canSpeech.value) {
|
||||||
return
|
return
|
||||||
@@ -326,13 +371,6 @@ function stopSpeech() {
|
|||||||
canSpeech.value = false
|
canSpeech.value = false
|
||||||
message.success('已停止监听')
|
message.success('已停止监听')
|
||||||
}
|
}
|
||||||
function cancelSpeech() {
|
|
||||||
EasySpeech.cancel()
|
|
||||||
isSpeaking.value = false
|
|
||||||
if (checkTimer) {
|
|
||||||
clearInterval(checkTimer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async function uploadConfig() {
|
async function uploadConfig() {
|
||||||
await QueryPostAPI(VTSURU_API_URL + 'set-config', {
|
await QueryPostAPI(VTSURU_API_URL + 'set-config', {
|
||||||
name: 'Speech',
|
name: 'Speech',
|
||||||
@@ -439,6 +477,9 @@ function test(type: EventDataTypes) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function testAPI() {
|
||||||
|
speakFromAPI('这是一条测试弹幕')
|
||||||
|
}
|
||||||
|
|
||||||
let speechQueueTimer: number
|
let speechQueueTimer: number
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -473,7 +514,8 @@ onUnmounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
例如 Microsoft Xiaoxiao Online (Natural) - Chinese (Mainland), 各种营销号就用的这些配音
|
例如 Microsoft Xiaoxiao Online (Natural) - Chinese (Mainland), 各种营销号就用的这些配音
|
||||||
</NTooltip>
|
</NTooltip>
|
||||||
系列语音, 效果要好很多
|
系列语音, 效果要好
|
||||||
|
<NText strong>很多很多</NText>
|
||||||
</NAlert>
|
</NAlert>
|
||||||
<NAlert type="info" closeable>
|
<NAlert type="info" closeable>
|
||||||
当在后台运行时请关闭浏览器的 页面休眠/内存节省功能. Chrome:
|
当在后台运行时请关闭浏览器的 页面休眠/内存节省功能. Chrome:
|
||||||
@@ -515,7 +557,17 @@ onUnmounted(() => {
|
|||||||
<template v-if="canSpeech">
|
<template v-if="canSpeech">
|
||||||
<NDivider> 状态 </NDivider>
|
<NDivider> 状态 </NDivider>
|
||||||
<NSpace vertical align="center">
|
<NSpace vertical align="center">
|
||||||
<NTooltip>
|
<NTooltip v-if="settings.voiceType == 'api' && isApiAudioLoading">
|
||||||
|
<template #trigger>
|
||||||
|
<NButton circle @click="cancelSpeech">
|
||||||
|
<template #icon>
|
||||||
|
<NSpin show />
|
||||||
|
</template>
|
||||||
|
</NButton>
|
||||||
|
</template>
|
||||||
|
取消
|
||||||
|
</NTooltip>
|
||||||
|
<NTooltip v-else>
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<NButton circle :disabled="!isSpeaking" @click="cancelSpeech" :style="`animation: ${isSpeaking ? 'animated-border 2.5s infinite;' : ''}`">
|
<NButton circle :disabled="!isSpeaking" @click="cancelSpeech" :style="`animation: ${isSpeaking ? 'animated-border 2.5s infinite;' : ''}`">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
@@ -555,22 +607,102 @@ onUnmounted(() => {
|
|||||||
</NCollapse>
|
</NCollapse>
|
||||||
</NSpace>
|
</NSpace>
|
||||||
</template>
|
</template>
|
||||||
<NDivider />
|
<NDivider>
|
||||||
<NSpace vertical>
|
<NRadioGroup v-model:value="settings.voiceType" size="small">
|
||||||
<NSelect v-model:value="settings.speechInfo.voice" :options="voiceOptions" :fallback-option="() => ({ label: '未选择, 将使用默认语音', value: '' })" />
|
<NRadioButton value="local">本地</NRadioButton>
|
||||||
<span style="width: 100%">
|
<NRadioButton value="api">
|
||||||
<NText> 音量 </NText>
|
API
|
||||||
<NSlider style="min-width: 200px" v-model:value="settings.speechInfo.volume" :min="0" :max="1" :step="0.01" />
|
|
||||||
</span>
|
<NTooltip>
|
||||||
<span style="width: 100%">
|
<template #trigger>
|
||||||
<NText> 音调 </NText>
|
<NIcon :component="Info24Filled" />
|
||||||
<NSlider style="min-width: 200px" v-model:value="settings.speechInfo.pitch" :min="0" :max="2" :step="0.01" />
|
</template>
|
||||||
</span>
|
自定义语音API, 可以播放自己训练的模型或者其他tts之类的
|
||||||
<span style="width: 100%">
|
</NTooltip>
|
||||||
<NText> 语速 </NText>
|
</NRadioButton>
|
||||||
<NSlider style="min-width: 200px" v-model:value="settings.speechInfo.rate" :min="0" :max="2" :step="0.01" />
|
</NRadioGroup>
|
||||||
</span>
|
</NDivider>
|
||||||
</NSpace>
|
<Transition name="fade" mode="out-in">
|
||||||
|
<NSpace v-if="settings.voiceType == 'local'" vertical>
|
||||||
|
<NSelect v-model:value="settings.speechInfo.voice" :options="voiceOptions" :fallback-option="() => ({ label: '未选择, 将使用默认语音', value: '' })" />
|
||||||
|
<span style="width: 100%">
|
||||||
|
<NText> 音量 </NText>
|
||||||
|
<NSlider style="min-width: 200px" v-model:value="settings.speechInfo.volume" :min="0" :max="1" :step="0.01" />
|
||||||
|
</span>
|
||||||
|
<span style="width: 100%">
|
||||||
|
<NText> 音调 </NText>
|
||||||
|
<NSlider style="min-width: 200px" v-model:value="settings.speechInfo.pitch" :min="0" :max="2" :step="0.01" />
|
||||||
|
</span>
|
||||||
|
<span style="width: 100%">
|
||||||
|
<NText> 语速 </NText>
|
||||||
|
<NSlider style="min-width: 200px" v-model:value="settings.speechInfo.rate" :min="0" :max="2" :step="0.01" />
|
||||||
|
</span>
|
||||||
|
</NSpace>
|
||||||
|
<template v-else>
|
||||||
|
<div>
|
||||||
|
<NCollapse>
|
||||||
|
<NCollapseItem title="要求" name="1">
|
||||||
|
<NUl>
|
||||||
|
<NLi> 直接返回音频数据 (wav, mp3, m4a etc.) </NLi>
|
||||||
|
<NLi>
|
||||||
|
最好使用HTTPS
|
||||||
|
<NTooltip>
|
||||||
|
<template #trigger>
|
||||||
|
<NIcon :component="Info24Filled" />
|
||||||
|
</template>
|
||||||
|
不使用https的话将会使用 cloudflare workers 进行代理, 会慢很多
|
||||||
|
</NTooltip>
|
||||||
|
</NLi>
|
||||||
|
<NLi> 指定API可以被外部访问 </NLi>
|
||||||
|
</NUl>
|
||||||
|
推荐项目:
|
||||||
|
<NButton text type="info" tag="a" href="https://github.com/Artrajz/vits-simple-api" target="_blank"> vits-simple-api </NButton>
|
||||||
|
</NCollapseItem>
|
||||||
|
</NCollapse>
|
||||||
|
<br />
|
||||||
|
<NSpace vertical>
|
||||||
|
<NAlert type="info">
|
||||||
|
地址中的
|
||||||
|
<NButton @click="copyToClipboard('{{text}}')" size="tiny" :bordered="false" type="primary" secondary>
|
||||||
|
{{ '\{\{ text \}\}' }}
|
||||||
|
</NButton>
|
||||||
|
将被替换为要念的文本
|
||||||
|
</NAlert>
|
||||||
|
<NAlert v-if="isVtsuruVoiceAPI" type="success">
|
||||||
|
看起来你正在使用本站提供的测试API (voice.vtsuru.live), 这个接口将会返回
|
||||||
|
<NButton text type="info" tag="a" href="https://space.bilibili.com/5859321" target="_blank"> Xz乔希 </NButton>
|
||||||
|
训练的 Taffy 模型结果, 不支持部分英文, 侵删
|
||||||
|
</NAlert>
|
||||||
|
</NSpace>
|
||||||
|
<br />
|
||||||
|
<NInputGroup>
|
||||||
|
<NSelect
|
||||||
|
v-model:value="settings.voiceAPISchemeType"
|
||||||
|
:options="[
|
||||||
|
{ label: 'https://', value: 'https' },
|
||||||
|
{ label: 'http://', value: 'http' },
|
||||||
|
]"
|
||||||
|
style="width: 110px"
|
||||||
|
/>
|
||||||
|
<NInput
|
||||||
|
v-model:value="settings.voiceAPI"
|
||||||
|
placeholder="API 地址, 例如 xxx.com/voice/bert-vits2?text={{text}}&id=0 (前面不要带https://)"
|
||||||
|
:status="/^(?:https?:\/\/)/.test(settings.voiceAPI?.toLowerCase() ?? '') ? 'error' : 'success'"
|
||||||
|
/>
|
||||||
|
<NButton @click="testAPI" type="info"> 测试 </NButton>
|
||||||
|
</NInputGroup>
|
||||||
|
<br /><br />
|
||||||
|
<NSpace vertical>
|
||||||
|
<NAlert v-if="settings.voiceAPISchemeType == 'http'" type="info"> 不使用https的话将会使用 cloudflare workers 进行代理, 会慢很多 </NAlert>
|
||||||
|
<span style="width: 100%">
|
||||||
|
<NText> 音量 </NText>
|
||||||
|
<NSlider style="min-width: 200px" v-model:value="settings.speechInfo.volume" :min="0" :max="1" :step="0.01" />
|
||||||
|
</span>
|
||||||
|
</NSpace>
|
||||||
|
<audio ref="apiAudio" :src="apiAudioSrc" :volume="settings.speechInfo.volume" @ended="cancelSpeech" @canplay="isApiAudioLoading = false" @error="onAPIError"></audio>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Transition>
|
||||||
<NDivider>
|
<NDivider>
|
||||||
自定义内容
|
自定义内容
|
||||||
<NTooltip>
|
<NTooltip>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"useDefineForClassFields": true,
|
"useDefineForClassFields": true,
|
||||||
|
"allowJs": false,
|
||||||
"sourceMap": false,
|
"sourceMap": false,
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"types": ["node"],
|
"types": ["node"],
|
||||||
|
|||||||
Reference in New Issue
Block a user