Commit 90ce5548 authored by ali's avatar ali

feat: TTS 接口接入,完成各项功能支持配置功能

parent 2a0caeea
...@@ -13,12 +13,13 @@ ...@@ -13,12 +13,13 @@
} }
], ],
"emmet.syntaxProfiles": {}, "emmet.syntaxProfiles": {},
"files.autoSave": "afterDelay", "files.autoSave": "off",
"editor.wordWrap": "on", "editor.wordWrap": "on",
"editor.defaultFormatter": "esbenp.prettier-vscode", "editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true, "editor.formatOnSave": true,
"editor.tabSize": 2, "editor.tabSize": 2,
"cSpell.words": [ "cSpell.words": [
"Vosk" "Vosk"
] ],
"editor.inlineSuggest.showToolbar": "always"
} }
{ {
"name": "vutron", "name": "laipic",
"version": "1.0.0", "version": "1.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "vutron", "name": "laipic",
"version": "1.0.0", "version": "1.0.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"pinia": "^2.1.7", "pinia": "^2.1.7",
"pinia-plugin-persistedstate": "^3.2.0",
"vue": "^3.3.7", "vue": "^3.3.7",
"vue-i18n": "^9.6.2", "vue-i18n": "^9.6.2",
"vue-router": "^4.2.5", "vue-router": "^4.2.5",
...@@ -6632,6 +6633,14 @@ ...@@ -6632,6 +6633,14 @@
} }
} }
}, },
"node_modules/pinia-plugin-persistedstate": {
"version": "3.2.0",
"resolved": "https://registry.npmmirror.com/pinia-plugin-persistedstate/-/pinia-plugin-persistedstate-3.2.0.tgz",
"integrity": "sha512-tZbNGf2vjAQcIm7alK40sE51Qu/m9oWr+rEgNm/2AWr1huFxj72CjvpQcIQzMknDBJEkQznCLAGtJTIcLKrKdw==",
"peerDependencies": {
"pinia": "^2.0.0"
}
},
"node_modules/pinia/node_modules/vue-demi": { "node_modules/pinia/node_modules/vue-demi": {
"version": "0.14.6", "version": "0.14.6",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.6.tgz", "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.6.tgz",
......
...@@ -32,6 +32,7 @@ ...@@ -32,6 +32,7 @@
}, },
"dependencies": { "dependencies": {
"pinia": "^2.1.7", "pinia": "^2.1.7",
"pinia-plugin-persistedstate": "^3.2.0",
"vue": "^3.3.7", "vue": "^3.3.7",
"vue-i18n": "^9.6.2", "vue-i18n": "^9.6.2",
"vue-router": "^4.2.5", "vue-router": "^4.2.5",
...@@ -69,4 +70,4 @@ ...@@ -69,4 +70,4 @@
"vue-tsc": "^1.8.22", "vue-tsc": "^1.8.22",
"xvfb-maybe": "^0.2.1" "xvfb-maybe": "^0.2.1"
} }
} }
\ No newline at end of file
import { BrowserWindow, ipcMain, shell } from 'electron' import { BrowserWindow, ipcMain, shell, BrowserWindowConstructorOptions } from 'electron'
import Constants from './utils/Constants' import Constants from './utils/Constants'
/* /*
* IPC Communications * IPC Communications
* */ * */
export default class IPCs { export default class IPCs {
static browserWindows: Map<string, BrowserWindow[]> = new Map();
static initialize(window: BrowserWindow): void { static initialize(window: BrowserWindow): void {
// Get application version // Get application version
ipcMain.on('msgRequestGetVersion', () => { ipcMain.on('msgRequestGetVersion', () => {
...@@ -15,5 +18,43 @@ export default class IPCs { ...@@ -15,5 +18,43 @@ export default class IPCs {
ipcMain.on('msgOpenExternalLink', async (event, url: string) => { ipcMain.on('msgOpenExternalLink', async (event, url: string) => {
await shell.openExternal(url) await shell.openExternal(url)
}) })
// open new window
ipcMain.on('openWindow', async (event, url: string, options: BrowserWindowConstructorOptions & { isCloseOther: boolean }) => {
const ops = Object.assign({}, { isCloseOther: true, frame: false, useContentSize: true, webPreferences: Constants.DEFAULT_WEB_PREFERENCES }, options);
if (IPCs.browserWindows.has(url) && ops.isCloseOther) {
const wins = IPCs.browserWindows.get(url);
wins?.forEach(w => !w.isDestroyed() && w.close());
IPCs.browserWindows.set(url, []);
}
const win = new BrowserWindow(ops)
win.setMenu(null)
win.once('ready-to-show', (): void => {
win.setAlwaysOnTop(true)
win.show()
win.focus()
win.setAlwaysOnTop(false)
})
win.webContents.on('did-frame-finish-load', (): void => {
if (Constants.IS_DEV_ENV) {
win.webContents.openDevTools()
}
})
await win.loadURL(`${Constants.APP_INDEX_URL_DEV}${url}`)
if (!IPCs.browserWindows.has(url)) {
IPCs.browserWindows.set(url, []);
}
IPCs.browserWindows.get(url)?.push(win);
return win;
})
} }
} }
...@@ -15,7 +15,8 @@ export default class Constants { ...@@ -15,7 +15,8 @@ export default class Constants {
nodeIntegration: false, nodeIntegration: false,
contextIsolation: true, contextIsolation: true,
enableRemoteModule: false, enableRemoteModule: false,
preload: join(__dirname, '../preload/index.js') preload: join(__dirname, '../preload/index.js'),
webSecurity: false
} }
static APP_INDEX_URL_DEV = 'http://localhost:5173/index.html' static APP_INDEX_URL_DEV = 'http://localhost:5173/index.html'
......
import { contextBridge, ipcRenderer } from 'electron' import { contextBridge, ipcRenderer } from 'electron'
// Whitelist of valid channels used for IPC communication (Send message from Renderer to Main) // Whitelist of valid channels used for IPC communication (Send message from Renderer to Main)
const mainAvailChannels: string[] = ['msgRequestGetVersion', 'msgOpenExternalLink'] const mainAvailChannels: string[] = ['msgRequestGetVersion', 'msgOpenExternalLink', 'openWindow']
const rendererAvailChannels: string[] = ['msgReceivedVersion'] const rendererAvailChannels: string[] = ['msgReceivedVersion']
contextBridge.exposeInMainWorld('mainApi', { contextBridge.exposeInMainWorld('mainApi', {
......
<script setup lang="tsx"> <script setup lang="tsx">
import { ref } from 'vue'; import { computed, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import useStore from '@/renderer/store';
import { storeToRefs } from 'pinia';
import { audioAiTTS } from '@/renderer/plugins/tts'
const router = useRouter() const router = useRouter()
const route: any = useRoute() const route: any = useRoute()
const { settings } = useStore();
const setting = storeToRefs(settings);
settings.getSource();
const handleRoute = (path: string): void => { const handleRoute = (path: string): void => {
router.push(path) router.push(path)
...@@ -14,13 +21,29 @@ const isCurrentRoute = (path: string): boolean => { ...@@ -14,13 +21,29 @@ const isCurrentRoute = (path: string): boolean => {
} }
const asrItems = ref([ const asrItems = ref([
'Web Speech API', // 'Web Speech API',
'Vosk Api', 'vosk_asr',
'Whisper Api' 'xf_asr'
// 'Whisper Api'
]); ]);
const asrSelect = ref(null); const asrSelect = ref(setting.asr);
function save() {
console.log(1); const source = computed(() => {
return setting.source.value.map(({ sourceId, sourceName, description, sex }) => {
const _sex = sex === 1 ? '女声' : '男声'
return {
value: sourceId,
title: `${sourceName} - ${_sex} - ${description}`
}
});
});
async function changeSource() {
const tone = setting.source.value.find(({ sourceId }) => setting.selectSource.value === sourceId);
if (!tone) return;
const res = await audioAiTTS({ host: settings.ttsHost, text: '你好,今天天气怎么样?', speed: 5.5, speaker: tone.sourceId, provider: tone.provider });
console.log(res);
} }
</script> </script>
...@@ -44,7 +67,7 @@ function save() { ...@@ -44,7 +67,7 @@ function save() {
视频数字人 视频数字人
</v-btn> </v-btn>
<v-dialog width="500"> <v-dialog width="600">
<template #activator="{ props }"> <template #activator="{ props }">
<v-btn <v-btn
v-bind="props" v-bind="props"
...@@ -55,32 +78,52 @@ function save() { ...@@ -55,32 +78,52 @@ function save() {
start start
icon="mdi-wrench" icon="mdi-wrench"
></v-icon> ></v-icon>
</v-btn> </v-btn>
</template> </template>
<template #default="{ isActive }"> <template #default="{ isActive }">
<v-card title="配置"> <v-card title="配置">
<v-sheet width="300" class="mx-auto"> <v-sheet width="500" class="mx-auto mt-6">
<v-form ref="form"> <v-form ref="form">
<v-select <v-select
v-model="asrSelect" v-model="setting.asr.value"
:items="asrItems" :items="asrItems"
:rules="[v => !!v || '请选择 Asr']" :rules="[v => !!v || '请选择 Asr']"
label="选择语音识别(ASR)" label="语音识别(ASR)"
required
></v-select>
<template v-if="asrSelect === 'vosk_asr'">
<v-select
v-model="setting.voskSelectModel.value"
:items="setting.voskModels.value"
label="vosk_asr 模型"
required
></v-select>
</template>
<v-text-field
label="TTS 域名"
:rules="[
value => !!value || 'TTS 域名必填',
]"
hide-details="auto"
:model-value="setting.ttsHost"
></v-text-field>
<v-select
v-model="setting.selectSource.value"
class="mt-6"
:items="source"
:rules="[v => !!v || '请选择音色']"
label="TTS 音色"
required required
@update:model-value="changeSource"
></v-select> ></v-select>
<v-btn
color="success"
class="mt-4"
block
@click="save"
>
保存
</v-btn>
</v-form> </v-form>
</v-sheet> </v-sheet>
......
...@@ -5,6 +5,10 @@ import App from '@/renderer/App.vue' ...@@ -5,6 +5,10 @@ import App from '@/renderer/App.vue'
import router from '@/renderer/router' import router from '@/renderer/router'
import vuetify from '@/renderer/plugins/vuetify' import vuetify from '@/renderer/plugins/vuetify'
import i18n from '@/renderer/plugins/i18n' import i18n from '@/renderer/plugins/i18n'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const pinia = createPinia();
pinia.use(piniaPluginPersistedstate);
// Add API key defined in contextBridge to window object type // Add API key defined in contextBridge to window object type
declare global { declare global {
...@@ -16,6 +20,6 @@ declare global { ...@@ -16,6 +20,6 @@ declare global {
const app = createApp(App) const app = createApp(App)
app.use(vuetify).use(i18n).use(router).use(createPinia()) app.use(vuetify).use(i18n).use(router).use(pinia)
app.mount('#app') app.mount('#app')
export async function audioAiTTS({ host, text, speaker, speed, provider }: { host: string; text: string; speaker: string; speed: number; provider: number; }) {
const resp = await fetch(`${ host }/api/live/audioAI`, {
"headers": {
"accept": "application/json, text/plain, */*",
"content-type": "application/json",
},
body: JSON.stringify({
type: 1,
text,
speaker,
speed,
provider,
pause_points: [],
sceneType: 1,
gender: 1
}),
method: "POST",
mode: "cors",
});
const res = await resp.json();
if (res.code !== 200) throw new Error(JSON.stringify(res));
return res.data;
}
\ No newline at end of file
export * from './FetchTTS'
\ No newline at end of file
...@@ -5,6 +5,11 @@ ...@@ -5,6 +5,11 @@
// import { useCounterStore } from '@/renderer/store/counter' // import { useCounterStore } from '@/renderer/store/counter'
// import { storeToRefs } from 'pinia' // import { storeToRefs } from 'pinia'
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import useStore from '@/renderer/store';
import { storeToRefs } from 'pinia';
const { photo: usePhoto } = useStore();
const photo = storeToRefs(usePhoto);
// const { availableLocales } = useI18n() // const { availableLocales } = useI18n()
// const { counterIncrease } = useCounterStore() // const { counterIncrease } = useCounterStore()
...@@ -22,40 +27,63 @@ onMounted((): void => { ...@@ -22,40 +27,63 @@ onMounted((): void => {
// window.mainApi.send('msgRequestGetVersion') // window.mainApi.send('msgRequestGetVersion')
}) })
const photoList = ref([ async function handleOpen(event: Event,url: string) {
{ await window.mainApi.send('openWindow', `#show?url=${url}`, { width: window.screen.width / 4 , height: window.screen.height });
url: 'https://resources.laihua.com/2023-11-2/93ffb6a7-ae93-4918-944e-877016ba266b.png' }
},
{ const validateURL = (url: string) => {
url: 'https://resources.laihua.com/2023-6-19/6fa9a127-2ce5-43ea-a543-475bf9354eda.png' const regex = /^(https?|ftp):\/\/([\w/\-?=%.]+\.[\w/\-?=%.]+)$/;
} return regex.test(url);
]); }
const urlValue = ref('');
const imgLoading = ref(false);
async function appendPhoto(url: string) {
urlValue.value = url;
let currentShowWin: Window | null = null; if (!validateURL(url)) return '请输入正确的 url!如(url(https://xxx.png)'
function handleOpen(event: Event,url: string) { try {
if (currentShowWin) { imgLoading.value = true;
currentShowWin.close(); const img = new Image();
img.src = url;
await new Promise((resolve, reject) => {
img.onload = resolve;
img.onerror = reject;
});
imgLoading.value = false;
} catch (error) {
imgLoading.value = false;
return '图片加载失败!'
} }
currentShowWin = window.open(`${location.href}show?url=${url}`, '_blank', `width=${ window.screen.width / 4 },height=${ window.screen.height },top=0,left=0,frame=false,nodeIntegration=no`); photo.list.value.push({ url });
urlValue.value = ''
return true;
}
function removePhoto(index: number) {
photo.list.value.splice(index, 1)
} }
</script> </script>
<template> <template>
<v-container class="d-flex mt-6"> <v-container class="d-flex mt-6 pb-0">
<v-sheet v-for="item in photoList" :key="item.url" :elevation="3" width="200" class="d-flex align-center spacing-playground pa-6 mr-4" rounded @click="handleOpen($event, item.url)"> <v-text-field label="自定义照片 url(https://xxx.png)" :model-value="urlValue" :loading="imgLoading" :rules="[ v => appendPhoto(v) ]" validate-on="blur lazy" ></v-text-field>
</v-container>
<v-container class="d-flex flex-wrap">
<v-sheet v-for="(item, index) in photo.list.value" :key="item.url" v-ripple :elevation="3" width="200" class="d-flex spacing-playground pa-6 mr-4 mt-4" rounded >
<v-img <v-img
:width="200" :width="200"
aspect-ratio="1/1" aspect-ratio="1/1"
cover cover
:src="item.url" :src="item.url"
@click="handleOpen($event, item.url)"
></v-img> ></v-img>
<v-btn density="compact" elevation="1" icon="mdi-close" class="mt-n7" @click="removePhoto(index)"></v-btn>
</v-sheet> </v-sheet>
</v-container> </v-container>
<v-container class="d-flex mt-6">
<v-text-field label="自定义照片 url(https://xxx.png)"></v-text-field>
</v-container>
</template> </template>
...@@ -3,9 +3,12 @@ import { ref } from 'vue'; ...@@ -3,9 +3,12 @@ import { ref } from 'vue';
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { Vosk } from '@/renderer/plugins/asr/index' import { Vosk } from '@/renderer/plugins/asr/index'
import type { ServerMessagePartialResult, ServerMessageResult, Model } from '@/renderer/plugins/asr/index' import type { ServerMessagePartialResult, ServerMessageResult, Model } from '@/renderer/plugins/asr/index'
import { audioAiTTS } from '../plugins/tts';
import useStore from '@/renderer/store';
const router = useRouter() const router = useRouter()
const route = useRoute(); const route = useRoute();
const { settings } = useStore();
const sampleRate = 48000; const sampleRate = 48000;
const recordVolume = ref(0); const recordVolume = ref(0);
...@@ -85,9 +88,14 @@ async function startAudioInput() { ...@@ -85,9 +88,14 @@ async function startAudioInput() {
microphoneState.value = 'loading'; microphoneState.value = 'loading';
const { recognizer, channel } = await initVosk({ const { recognizer, channel } = await initVosk({
modelPath: new URL('/vosk/models/vosk-model-small-cn-0.3.tar.gz', import.meta.url).href, modelPath: new URL(`/vosk/models/${settings.voskSelectModel}`, import.meta.url).href,
result: text => { result: async (text) => {
console.log('----------------> result:', text); console.log('----------------> text:', text);
const tone = settings.source.find(({ sourceId }) => settings.selectSource === sourceId);
if (!tone) return;
const res = await audioAiTTS({ host: settings.ttsHost, text, speed: 3, speaker: tone.sourceId, provider: tone.provider });
console.log('----------------> tts:', res);
}, },
partialResult: text => { partialResult: text => {
console.log('----------------> partialResult:', text); console.log('----------------> partialResult:', text);
......
import useSettings from './settings';
import usePhoto from './photo';
export default function useStore() {
return {
settings: useSettings(),
photo: usePhoto()
}
}
\ No newline at end of file
import { defineStore } from 'pinia'
type IPhoto = {
list: { url: string }[]
}
const usePhotoStore = defineStore('photo', {
persist: true,
state: () => ({
list: [
{
url: 'https://resources.laihua.com/2023-11-2/93ffb6a7-ae93-4918-944e-877016ba266b.png'
},
{
url: 'https://resources.laihua.com/2023-6-19/6fa9a127-2ce5-43ea-a543-475bf9354eda.png'
}
]
} as IPhoto),
getters: {
},
actions: {
}
})
export default usePhotoStore
\ No newline at end of file
import { defineStore } from 'pinia'
export type ISettings = {
asr: 'vosk_asr' | 'xf_asr';
voskModels: string[];
voskSelectModel: string;
ttsHost: string;
source: { sourceName: string; sourceId: string; provider: number; speaker: string; description: string; sex: 1 | 0 }[];
selectSource: string;
}
const useSettingsStore = defineStore('settings', {
persist: true,
state: () => ({
asr: 'vosk_asr',
voskModels: [
'vosk-model-small-ca-0.4.tar.gz',
'vosk-model-small-cn-0.3.tar.gz',
'vosk-model-small-de-0.15.tar.gz',
'vosk-model-small-en-in-0.4.tar.gz',
'vosk-model-small-en-us-0.15.tar.gz',
'vosk-model-small-es-0.3.tar.gz',
'vosk-model-small-fa-0.4.tar.gz',
'vosk-model-small-fr-pguyot-0.3.tar.gz',
'vosk-model-small-it-0.4.tar.gz',
'vosk-model-small-pt-0.3.tar.gz',
'vosk-model-small-ru-0.4.tar.gz',
'vosk-model-small-tr-0.3.tar.gz',
'vosk-model-small-vn-0.3.tar.gz'
],
voskSelectModel: 'vosk-model-small-cn-0.3.tar.gz',
ttsHost: 'https://beta.laihua.com',
source: [],
selectSource: ''
} as ISettings),
getters: {
},
actions: {
async getSource() {
const resp = await fetch(`${this.$state.ttsHost}/api/live/audioAI/source?platform=31`, {
"method": "GET",
"mode": "cors",
});
const res = await resp.json();
if (res.code !== 200) return;
this.source = res.data;
}
}
})
export default useSettingsStore;
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment