Commit 90ce5548 authored by ali's avatar ali

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

parent 2a0caeea
......@@ -13,12 +13,13 @@
}
],
"emmet.syntaxProfiles": {},
"files.autoSave": "afterDelay",
"files.autoSave": "off",
"editor.wordWrap": "on",
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.tabSize": 2,
"cSpell.words": [
"Vosk"
]
],
"editor.inlineSuggest.showToolbar": "always"
}
{
"name": "vutron",
"name": "laipic",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "vutron",
"name": "laipic",
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"pinia": "^2.1.7",
"pinia-plugin-persistedstate": "^3.2.0",
"vue": "^3.3.7",
"vue-i18n": "^9.6.2",
"vue-router": "^4.2.5",
......@@ -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": {
"version": "0.14.6",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.6.tgz",
......
......@@ -32,6 +32,7 @@
},
"dependencies": {
"pinia": "^2.1.7",
"pinia-plugin-persistedstate": "^3.2.0",
"vue": "^3.3.7",
"vue-i18n": "^9.6.2",
"vue-router": "^4.2.5",
......
import { BrowserWindow, ipcMain, shell } from 'electron'
import { BrowserWindow, ipcMain, shell, BrowserWindowConstructorOptions } from 'electron'
import Constants from './utils/Constants'
/*
* IPC Communications
* */
export default class IPCs {
static browserWindows: Map<string, BrowserWindow[]> = new Map();
static initialize(window: BrowserWindow): void {
// Get application version
ipcMain.on('msgRequestGetVersion', () => {
......@@ -15,5 +18,43 @@ export default class IPCs {
ipcMain.on('msgOpenExternalLink', async (event, url: string) => {
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 {
nodeIntegration: false,
contextIsolation: true,
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'
......
import { contextBridge, ipcRenderer } from 'electron'
// 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']
contextBridge.exposeInMainWorld('mainApi', {
......
<script setup lang="tsx">
import { ref } from 'vue';
import { computed, ref } from 'vue';
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 route: any = useRoute()
const { settings } = useStore();
const setting = storeToRefs(settings);
settings.getSource();
const handleRoute = (path: string): void => {
router.push(path)
......@@ -14,13 +21,29 @@ const isCurrentRoute = (path: string): boolean => {
}
const asrItems = ref([
'Web Speech API',
'Vosk Api',
'Whisper Api'
// 'Web Speech API',
'vosk_asr',
'xf_asr'
// 'Whisper Api'
]);
const asrSelect = ref(null);
function save() {
console.log(1);
const asrSelect = ref(setting.asr);
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>
......@@ -44,7 +67,7 @@ function save() {
视频数字人
</v-btn>
<v-dialog width="500">
<v-dialog width="600">
<template #activator="{ props }">
<v-btn
v-bind="props"
......@@ -55,32 +78,52 @@ function save() {
start
icon="mdi-wrench"
></v-icon>
</v-btn>
</template>
<template #default="{ isActive }">
<v-card title="配置">
<v-sheet width="300" class="mx-auto">
<v-sheet width="500" class="mx-auto mt-6">
<v-form ref="form">
<v-select
v-model="asrSelect"
v-model="setting.asr.value"
:items="asrItems"
:rules="[v => !!v || '请选择 Asr']"
label="选择语音识别(ASR)"
label="语音识别(ASR)"
required
></v-select>
<v-btn
color="success"
class="mt-4"
block
@click="save"
>
保存
</v-btn>
<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
@update:model-value="changeSource"
></v-select>
</v-form>
</v-sheet>
......
......@@ -5,6 +5,10 @@ import App from '@/renderer/App.vue'
import router from '@/renderer/router'
import vuetify from '@/renderer/plugins/vuetify'
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
declare global {
......@@ -16,6 +20,6 @@ declare global {
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')
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 @@
// import { useCounterStore } from '@/renderer/store/counter'
// import { storeToRefs } from 'pinia'
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 { counterIncrease } = useCounterStore()
......@@ -22,40 +27,63 @@ onMounted((): void => {
// window.mainApi.send('msgRequestGetVersion')
})
const photoList = ref([
{
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'
}
]);
async function handleOpen(event: Event,url: string) {
await window.mainApi.send('openWindow', `#show?url=${url}`, { width: window.screen.width / 4 , height: window.screen.height });
}
const validateURL = (url: string) => {
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) {
if (currentShowWin) {
currentShowWin.close();
try {
imgLoading.value = true;
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>
<template>
<v-container class="d-flex mt-6">
<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-container class="d-flex mt-6 pb-0">
<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
:width="200"
aspect-ratio="1/1"
cover
:src="item.url"
@click="handleOpen($event, item.url)"
></v-img>
<v-btn density="compact" elevation="1" icon="mdi-close" class="mt-n7" @click="removePhoto(index)"></v-btn>
</v-sheet>
</v-container>
<v-container class="d-flex mt-6">
<v-text-field label="自定义照片 url(https://xxx.png)"></v-text-field>
</v-container>
</template>
......@@ -3,9 +3,12 @@ import { ref } from 'vue';
import { useRoute, useRouter } from 'vue-router'
import { Vosk } 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 route = useRoute();
const { settings } = useStore();
const sampleRate = 48000;
const recordVolume = ref(0);
......@@ -85,9 +88,14 @@ async function startAudioInput() {
microphoneState.value = 'loading';
const { recognizer, channel } = await initVosk({
modelPath: new URL('/vosk/models/vosk-model-small-cn-0.3.tar.gz', import.meta.url).href,
result: text => {
console.log('----------------> result:', text);
modelPath: new URL(`/vosk/models/${settings.voskSelectModel}`, import.meta.url).href,
result: async (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 => {
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