feat: 合成语音
This commit is contained in:
parent
36fad9c1ca
commit
af2f9398b7
@ -11,4 +11,6 @@ VITE_API_URL = http://192.168.1.101:13080
|
||||
VITE_WS_URL = ws://192.168.1.101:13080/ws
|
||||
|
||||
# explanation
|
||||
VITE_INSPECTION_TYPE = "inspection"
|
||||
VITE_INSPECTION_TYPE = "inspection"
|
||||
|
||||
VITE_MINIO_URL = "http://192.168.28.10:9000/cmvr-iot/"
|
||||
@ -17,4 +17,6 @@ VITE_API_URL = http://192.168.28.10:13080
|
||||
VITE_WS_URL = ws://192.168.0.201:13080/ws
|
||||
|
||||
# explanation
|
||||
VITE_INSPECTION_TYPE = "inspection"
|
||||
VITE_INSPECTION_TYPE = "inspection"
|
||||
|
||||
VITE_MINIO_URL = "http://192.168.28.10:9000/cmvr-iot/"
|
||||
@ -19,6 +19,7 @@
|
||||
"@vueup/vue-quill": "1.2.0",
|
||||
"@vueuse/core": "10.11.0",
|
||||
"ace-builds": "^1.43.0",
|
||||
"autofit.js": "^3.2.8",
|
||||
"axios": "0.28.1",
|
||||
"clipboard": "2.0.11",
|
||||
"dayjs": "^1.11.13",
|
||||
|
||||
@ -43,4 +43,20 @@ export function regenerateApi(id) {
|
||||
url: `/tts/task/regenerate/${id}`,
|
||||
method: 'post'
|
||||
})
|
||||
}
|
||||
|
||||
export function generalizationApi(data) {
|
||||
return request({
|
||||
url: '/tts/task/llm/query',
|
||||
method: 'post',
|
||||
data: data
|
||||
})
|
||||
}
|
||||
|
||||
export function batchTaskApi(data) {
|
||||
return request({
|
||||
url: '/tts/task/batch',
|
||||
method: 'post',
|
||||
data: data
|
||||
})
|
||||
}
|
||||
@ -1,7 +1,8 @@
|
||||
#app {
|
||||
|
||||
.main-container {
|
||||
min-height: 100%;
|
||||
// min-height: 100%;
|
||||
height: 100%;
|
||||
transition: margin-left .28s;
|
||||
margin-left: $base-sidebar-width;
|
||||
position: relative;
|
||||
|
||||
59
src/components/IPlayer/config.ts
Normal file
59
src/components/IPlayer/config.ts
Normal file
@ -0,0 +1,59 @@
|
||||
export const conf = {
|
||||
/**
|
||||
* 基础功能配置
|
||||
* */
|
||||
autoplay: false, // 是否自动播放,不自动播放,浏览器有限制规则
|
||||
autoplayMuted: false, // 是否自动播放(静音播放)
|
||||
videoInit: true, // 是否默认初始化video,默认初始化,默认true
|
||||
playsinline: true, // 是否启用内联播放模式,仅移动端生效
|
||||
defaultPlaybackRate: 1, // 默认播放速度(可选:0.5/0.75/1/1.5/2等)
|
||||
volume: 0.72, // 播放音量(可选:0 ~ 1)
|
||||
loop: false, // 是否循环播放,默认不循环播放
|
||||
startTime: 0, // 点播模式下,初始起播时间
|
||||
videoAttributes: {}, // video扩展属性,暂且不配置
|
||||
lang: 'zh-cn', // 播放器初始显示语言,设置为中文
|
||||
fluid: true, // 是否流式布局(宽高优先于流失布局,默认16:9)注掉上方宽高看效果
|
||||
fitVideoSize: 'fixed', // 保持容器宽/高,不做适配,按照容器来
|
||||
videoFillMode: 'auto', // 宽高不够自动底色填充(fill拉伸填充等...)
|
||||
seekedStatus: 'play', // 跳转后继续播放
|
||||
// 播放器进度条故事点信息数组
|
||||
progressDot: [
|
||||
{
|
||||
id: 0, // 唯一标识,用于删除的时候索引
|
||||
time: 30, // 展示的时间点,例子为在播放到10s钟的时候展示
|
||||
text: '进度条信息提示...', // hover的时候展示文案,可以为空
|
||||
duration: 1, // 展示时间跨度,单位为s
|
||||
style: { // 指定样式
|
||||
backgroundColor: 'white'
|
||||
}
|
||||
},
|
||||
],
|
||||
thumbnail: null, // 进度条预览图配置,普通业务用不到
|
||||
marginControls: false, // 是否开启画面和控制栏分离模式,不开启空间多一些
|
||||
domEventType: 'default', // 响应的事件类型,不用指定,用默认的即可
|
||||
/**
|
||||
* 交互功能配置(一般使用默认即可)
|
||||
* */
|
||||
/**
|
||||
* 插件配置,根据需求自选
|
||||
* */
|
||||
icons: {}, // 使用默认的icon图标
|
||||
i18n: [], // 使用默认的中文
|
||||
// 自定义一些颜色
|
||||
commonStyle: {
|
||||
progressColor: 'green', // 整个进度条颜色
|
||||
playedColor: 'chocolate', // 已播放的进度条颜色
|
||||
volumeColor: 'pink', // 音量大小竖向滑块颜色
|
||||
},
|
||||
controls: true, // 是否使用底部控制栏,默认使用
|
||||
miniprogress: true, // 是否使用mini进度条(当底部控制栏隐藏时生效)
|
||||
screenShot: false, // 关闭截图功能
|
||||
rotate: true, // 是否使用视频旋转插件,默认不使用
|
||||
download: true, // 是否使用下载按钮,一般不用,一般自定义控制
|
||||
pip: true, // 使用使用画中画模式,默认不用
|
||||
mini: true, // 是否使用小屏幕控件
|
||||
cssFullscreen: true, // 是否使用网页样式全屏按钮开关
|
||||
playbackRate: [0.5, 1, 1.5, 2, 3], //传入倍速可选数组
|
||||
// playbackRate: true, //false,禁用倍速播放(即控制栏不显示)
|
||||
keyShortcut: false, // 是否开启快捷键模式
|
||||
}
|
||||
62
src/components/IPlayer/index.vue
Normal file
62
src/components/IPlayer/index.vue
Normal file
@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<div class="player_container">
|
||||
<div ref="xgPlayerRef"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import Player, { Events } from 'xgplayer'; // 引入西瓜视频模块
|
||||
import 'xgplayer/dist/index.min.css'; // 引入西瓜视频样式
|
||||
|
||||
const { videoUrl } = defineProps({
|
||||
videoUrl: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const xgPlayerRef = ref()
|
||||
|
||||
import { conf } from "./config"; // 配置文件单独拎出来一个js文件
|
||||
|
||||
onMounted(() => { init() })
|
||||
|
||||
let player = null // 实例
|
||||
|
||||
const init = () => {
|
||||
player = new Player({
|
||||
el: xgPlayerRef.value,
|
||||
// width: 600,
|
||||
// height: 400, // 视频宽高尺寸
|
||||
isLive: false,
|
||||
url: videoUrl, // 视频源
|
||||
// poster: "http://ashuai.work/static/img/avantar.png", // 视频封面
|
||||
...conf,
|
||||
});
|
||||
player.on(Events.PLAY, (ev) => {
|
||||
console.log('-播放开始-', ev);
|
||||
})
|
||||
player.on(Events.PAUSE, (ev) => {
|
||||
console.log('-播放结束-', ev);
|
||||
})
|
||||
player.on('loadedmetadata', (ev) => {
|
||||
console.log('-媒体数据加载好了-', ev);
|
||||
})
|
||||
player.on(Events.SEEKED, (ev) => {
|
||||
console.log('-跳着播放-', ev);
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.player_container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
.xgplayer {
|
||||
height: 100% !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -37,7 +37,7 @@ function addIframe() {
|
||||
@import "@/assets/styles/variables.module";
|
||||
.app-main {
|
||||
/* 50= navbar 50 */
|
||||
height: calc(100vh - 84px);
|
||||
height: calc(100% - 84px);
|
||||
padding: 20px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
@ -54,7 +54,7 @@ function addIframe() {
|
||||
.hasTagsView {
|
||||
.app-main {
|
||||
/* 84 = navbar + tags-view = 50 + 34 */
|
||||
height: calc(100vh - 84px);
|
||||
height: calc(100% - 84px);
|
||||
}
|
||||
|
||||
.fixed-header + .app-main {
|
||||
|
||||
@ -1,19 +1,13 @@
|
||||
<template>
|
||||
<el-card shadow="never" class="corpus-list">
|
||||
<div class="corpus-list">
|
||||
<!-- 工具栏 -->
|
||||
<div class="list-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<el-button type="primary" size="default">+新建语料</el-button>
|
||||
<el-button type="primary" size="default" @click="openDialog(true)">+新建语料</el-button>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<el-tag
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
:type="activeTab === tab.key ? '' : 'info'"
|
||||
:effect="activeTab === tab.key ? 'dark' : 'plain'"
|
||||
style="cursor:pointer;"
|
||||
@click="activeTab = tab.key"
|
||||
>
|
||||
<el-tag v-for="tab in tabs" :key="tab.key" :type="activeTab === tab.key ? '' : 'info'"
|
||||
:effect="activeTab === tab.key ? 'dark' : 'plain'" style="cursor:pointer;" @click="activeTab = tab.key">
|
||||
{{ tab.label }}
|
||||
<span class="tab-count">{{ tab.count }}</span>
|
||||
</el-tag>
|
||||
@ -21,7 +15,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 表格 -->
|
||||
<el-table :data="filteredData" stripe style="width:100%;" max-height="400">
|
||||
<el-table :data="corpusData" stripe style="width:100%;" max-height="400">
|
||||
<el-table-column prop="id" label="语料编号" width="180" />
|
||||
<el-table-column prop="text" label="文本内容" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="category" label="功能类别" width="150" show-overflow-tooltip />
|
||||
@ -47,27 +41,151 @@
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-wrapper">
|
||||
<span class="text-muted text-sm">共 {{ total }} 条</span>
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next, jumper"
|
||||
:total="total"
|
||||
:page-size="10"
|
||||
v-model:current-page="page"
|
||||
small
|
||||
/>
|
||||
<el-pagination background layout="prev, pager, next, jumper" :total="total" :page-size="10"
|
||||
v-model:current-page="page" small />
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="'新建语料'" :append-to-body="true" width="600px" @close="resetDialog">
|
||||
<el-form ref="ruleFormRef" :model="ruleForm" label-width="auto">
|
||||
<el-form-item label="模型" prop="model">
|
||||
<el-select v-model="ruleForm.model" placeholder="模型">
|
||||
<el-option label="科大讯飞" value="科大讯飞" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="文本" prop="text">
|
||||
<el-input type="textarea" :rows="2" v-model="ruleForm.text" placeholder="请输入文本" />
|
||||
</el-form-item>
|
||||
<el-form-item label="语种" prop="language">
|
||||
<el-select v-model="ruleForm.language" placeholder="语种">
|
||||
<el-option v-for="item in langOption" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="上传文件">
|
||||
<el-upload
|
||||
v-model:file-list="uploadFileList"
|
||||
:action="`${data.baseUrl}/system/file/upload`"
|
||||
:before-upload="beforeAvatarUpload"
|
||||
:data="{fileType:uploadFileType}"
|
||||
:headers="{
|
||||
'authorization': getToken()
|
||||
}"
|
||||
:limit="1"
|
||||
:on-error="uploadError"
|
||||
:on-success="uploadSuccess"
|
||||
class="file-uploader"
|
||||
>
|
||||
<el-button type="primary">选取文件</el-button>
|
||||
<template #tip>
|
||||
<div class="el-upload__tip">
|
||||
支持格式:'wav'
|
||||
</div>
|
||||
</template>
|
||||
</el-upload>
|
||||
</el-form-item>
|
||||
|
||||
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="confirmDialog">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import {getToken} from "@/utils/auth.js";
|
||||
|
||||
const emit = defineEmits(['view-detail'])
|
||||
const dialogVisible = ref(false)
|
||||
|
||||
const voiceOption = ref([
|
||||
{
|
||||
label: '女声',
|
||||
value: 'xiaoyan'
|
||||
}
|
||||
])
|
||||
|
||||
const langOption = ref([
|
||||
{
|
||||
label: '中文(普通话)',
|
||||
value: '普通话'
|
||||
}, {
|
||||
label: '英文',
|
||||
value: '英文'
|
||||
}, {
|
||||
label: '粤语',
|
||||
value: '粤语'
|
||||
}, {
|
||||
label: '四川话',
|
||||
value: '四川话'
|
||||
},
|
||||
])
|
||||
|
||||
const ruleForm = ref({
|
||||
text: '',
|
||||
model: '科大讯飞',
|
||||
voice: 'xiaoyan',
|
||||
speed: 50,
|
||||
volume: 50,
|
||||
pitch: 50,
|
||||
sampleRate: '16',
|
||||
outputFormat: 'wav'
|
||||
})
|
||||
|
||||
const rules = ref({
|
||||
text: [{ required: true, message: '请输入合成文本', trigger: 'blur' }],
|
||||
language: [{ required: true, message: '请选择语种', trigger: 'blur' }]
|
||||
})
|
||||
|
||||
const uploadFileList = ref([])
|
||||
const uploadFileType = ref(null)
|
||||
const data = {
|
||||
baseUrl: import.meta.env.VITE_APP_BASE_API
|
||||
}
|
||||
|
||||
const openDialog = () => {
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const resetDialog = () => {
|
||||
|
||||
}
|
||||
|
||||
const searchText = ref('')
|
||||
const activeTab = ref('all')
|
||||
const page = ref(1)
|
||||
|
||||
const beforeAvatarUpload = (rawFile) => {
|
||||
const fileName = rawFile.name.split('.');
|
||||
const fileExt = fileName[fileName.length - 1];
|
||||
const isTypeOk = ['png', 'jpg', 'jpeg'].indexOf(fileExt) >= 0;
|
||||
if (!isTypeOk) {
|
||||
proxy.$modal.msgError(`文件格式不正确,请上传${['png', 'jpg', 'jpeg'].join("/")}格式文件!`);
|
||||
return false;
|
||||
}
|
||||
const {label = undefined, value = undefined} = supportType?.find(item => item.label === rawFile.type)
|
||||
if (label) {
|
||||
uploadFileType.value = value
|
||||
}
|
||||
// 校检文件大小
|
||||
|
||||
const isLt = rawFile.size / 1024 / 1024 < 5;
|
||||
if (!isLt) {
|
||||
ElMessage.error(`上传文件大小不能超过 ${5} MB!`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const uploadSuccess = (e) => {
|
||||
imgUrl.value = e.msg
|
||||
ElMessage.success('上传成功')
|
||||
}
|
||||
const uploadError = (e) => {
|
||||
console.log("uploadError", e)
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ key: 'all', label: '全部语料', count: '1,245,678' },
|
||||
{ key: 'pending', label: '待审核', count: '45,678' },
|
||||
@ -86,55 +204,40 @@ const corpusData = ref([
|
||||
{ id: 'CORP_20240517_0008', text: '关闭所有车窗', category: '控制指令-车窗控制', lang: '中文', dialect: '普通话', emotion: '中性', voice: '男声', status: '已通过', updateTime: '2024-05-17 10:06:20' }
|
||||
])
|
||||
|
||||
const filteredData = computed(() => {
|
||||
let list = corpusData.value
|
||||
if (activeTab.value === 'pending') {
|
||||
list = list.filter(item => item.status === '待审核')
|
||||
} else if (activeTab.value === 'published') {
|
||||
list = list.filter(item => item.status === '已通过')
|
||||
}
|
||||
if (searchText.value) {
|
||||
const s = searchText.value.toLowerCase()
|
||||
list = list.filter(item =>
|
||||
item.id.toLowerCase().includes(s) ||
|
||||
item.text.includes(s) ||
|
||||
item.voice.includes(s)
|
||||
)
|
||||
}
|
||||
return list
|
||||
})
|
||||
|
||||
const total = computed(() => filteredData.value.length)
|
||||
const total = computed(() => corpusData.value.length)
|
||||
|
||||
const statusType = (status) => {
|
||||
if (status === '已通过') return 'success'
|
||||
if (status === '待审核') return 'warning'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
const handleDetail = (row) => {
|
||||
emit('view-detail', row)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.corpus-list {
|
||||
background: #fff;
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
|
||||
.list-toolbar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.tab-count {
|
||||
margin-left: 4px;
|
||||
color: #94a3b8;
|
||||
@ -142,13 +245,20 @@ const handleDetail = (row) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-wrapper {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.text-muted { color: #94a3b8; }
|
||||
.text-sm { font-size: 13px; }
|
||||
|
||||
.text-muted {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.text-sm {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,20 +1,77 @@
|
||||
<template>
|
||||
<div class="main-workspace">
|
||||
<div class="workspace-container">
|
||||
<!-- 左栏:文本与参数配置 -->
|
||||
<div class="left">
|
||||
<div class="left">
|
||||
<div class="title">泛化语料</div>
|
||||
<el-form ref="generalizationFormRef" :rules="generalizationRules" :model="generalizationForm" label-position="top" label-width="auto">
|
||||
<el-row>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="" prop="text">
|
||||
<el-input type="textarea" :rows="2" v-model="generalizationForm.text" placeholder="请输入待合成文本" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="语种" prop="language">
|
||||
<el-select v-model="generalizationForm.language" placeholder="语种">
|
||||
<el-option v-for="item in languageOption" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="数量" prop="count">
|
||||
<el-input-number v-model="generalizationForm.count" :min="1" :max="10" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
<el-row style="margin-top: 10px;" :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-button :loading="generalizationLoading" type="primary" :icon="VideoPlay" class="btn"
|
||||
@click="generalization">开始泛化</el-button>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-button size="small" type="primary" plain :icon="Delete" class="btn"
|
||||
@click="generalizationCancel">清空</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<div v-if="corpusList.length > 0" >
|
||||
<el-row style="margin: 10px 0;">
|
||||
<el-col :span="24">泛化语料列表</el-col>
|
||||
</el-row>
|
||||
<div class="corpus-container">
|
||||
<el-row :gutter="20" v-for="(item, index) in corpusList" style="margin-bottom: 4px;">
|
||||
<el-col :span="22" >
|
||||
<el-input v-model="corpusList[index]"></el-input>
|
||||
</el-col>
|
||||
<el-col :span="2">
|
||||
<el-button icon="Plus" @click="addTag(item)"></el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="right">
|
||||
<div class="title">
|
||||
文本与参数配置
|
||||
</div>
|
||||
<el-form ref="formRef" :rules="rules" :model="form" label-position="top" label-width="auto">
|
||||
<el-row>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="" prop="text">
|
||||
<el-input type="textarea" :rows="3" v-model="form.text" placeholder="请输入待合成文本" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-form-item label="待合成语料" prop="tags">
|
||||
<el-tag v-for="tag in form.tags" :key="tag.name" closable @close="handleClose(tag)" class="tag">
|
||||
{{ tag }}
|
||||
</el-tag>
|
||||
</el-form-item>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="模型" prop="voice">
|
||||
<el-select v-model="form.model" placeholder="模型">
|
||||
<el-option label="科大讯飞" value="科大讯飞" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="音色" prop="voice">
|
||||
<el-select v-model="form.voice" placeholder="音色">
|
||||
@ -29,9 +86,7 @@
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="语速" prop="speed">
|
||||
<el-select v-model="form.speed" placeholder="语速">
|
||||
<el-slider v-model="form.speed" :min="1" :max="100" :step="1" />
|
||||
</el-select>
|
||||
<el-slider v-model="form.speed" :min="1" :max="100" :step="1" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
@ -41,9 +96,9 @@
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="采样率">
|
||||
<el-select v-model="form.auf" placeholder="采样率">
|
||||
<el-option label="8kHz" value="8" />
|
||||
<el-option label="16kHz" value="16" />
|
||||
<el-select v-model="form.sampleRate" placeholder="采样率">
|
||||
<el-option label="8kHz" value="8000" />
|
||||
<el-option label="16kHz" value="16000" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
@ -51,14 +106,13 @@
|
||||
<el-form-item label="输出格式">
|
||||
<el-select v-model="form.outputFormat" placeholder="输出格式">
|
||||
<el-option label="WAV" value="wav" />
|
||||
<el-option label="男声-沉稳" value="male-calm" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row style="margin-top: 20px;" :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-button size="small" type="primary" :icon="VideoPlay" class="btn"
|
||||
<el-button :loading="loading" type="primary" :icon="VideoPlay" class="btn"
|
||||
@click="synthesis">开始合成</el-button>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
@ -68,38 +122,6 @@
|
||||
</el-row>
|
||||
</el-form>
|
||||
</div>
|
||||
<!-- 右栏:语音预览与波形 -->
|
||||
<div class="right">
|
||||
<div class="title">语音预览与波形</div>
|
||||
<div class="wave-container">
|
||||
<div class="wave">
|
||||
<div v-for="(h, index) in waveform" :key="index" :style="{
|
||||
width: '4px',
|
||||
height: h + '%',
|
||||
background: '#3b82f6',
|
||||
borderRadius: '2px',
|
||||
opacity: 0.6 + (h / 100) * 0.4
|
||||
}"></div>
|
||||
</div>
|
||||
<div class="audio-container">
|
||||
<AudioPlayer :src="audioUrl" @audioStatus="audioStatus" />
|
||||
</div>
|
||||
<div class="card-container">
|
||||
<div v-for="item in cardList" class="card">
|
||||
<div class="title">{{ item.title }}</div>
|
||||
<div class="value">{{ item.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-row style="margin-top: 10px;" :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-button type="primary" plain class="btn" @click="regenerate">重新生成</el-button>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-button type="primary" class="btn" >保存</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -107,28 +129,24 @@
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { Delete, VideoPlay } from '@element-plus/icons-vue'
|
||||
import { taskApi, regenerateApi } from '@/api/corpusManger/speech_synthesis'
|
||||
import AudioPlayer from './AudioPlayer.vue';
|
||||
import { batchTaskApi, regenerateApi, generalizationApi } from '@/api/corpusManger/speech_synthesis'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const emit = defineEmits(['refresh'])
|
||||
|
||||
const form = ref({
|
||||
const generalizationFormRef = ref()
|
||||
const generalizationForm = ref({
|
||||
text: '',
|
||||
voice: 'xiaoyan',
|
||||
speed: 50,
|
||||
volume: 50,
|
||||
pitch: 50,
|
||||
auf: '16',
|
||||
outputFormat: 'wav'
|
||||
language: '普通话',
|
||||
count: 1
|
||||
})
|
||||
|
||||
const formRef = ref(null)
|
||||
|
||||
const rules = reactive({
|
||||
const generalizationRules = ref({
|
||||
text: [{ required: true, message: '请输入合成文本', trigger: 'blur' }],
|
||||
voice: [{ required: true, message: '请输入音色', trigger: 'blur' }]
|
||||
language: [{ required: true, message: '请选择语种', trigger: 'blur' }]
|
||||
})
|
||||
|
||||
const langOption = ref([
|
||||
|
||||
const languageOption = ref([
|
||||
{
|
||||
label: '中文(普通话)',
|
||||
value: '普通话'
|
||||
@ -141,9 +159,67 @@ const langOption = ref([
|
||||
}, {
|
||||
label: '四川话',
|
||||
value: '四川话'
|
||||
},
|
||||
}
|
||||
])
|
||||
|
||||
const corpusList = ref([])
|
||||
const generalizationLoading = ref(false)
|
||||
const generalization = async () => {
|
||||
const result = await generalizationFormRef.value.validate()
|
||||
if (result) {
|
||||
generalizationLoading.value = true
|
||||
const res = await generalizationApi(generalizationForm.value)
|
||||
if (res.code === 200) {
|
||||
corpusList.value = res.data
|
||||
ElMessage.success(res.msg)
|
||||
} else {
|
||||
ElMessage.error(res.msg)
|
||||
}
|
||||
generalizationLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const generalizationCancel = () => {
|
||||
generalizationFormRef.value.resetFields()
|
||||
}
|
||||
|
||||
const addTag = (data) => {
|
||||
console.log('data', data)
|
||||
form.value.tags.push(data)
|
||||
}
|
||||
|
||||
// ========================================================
|
||||
const handleClose = (tag) => {
|
||||
form.value.tags.splice(form.value.tags.indexOf(tag), 1)
|
||||
}
|
||||
|
||||
const form = ref({
|
||||
tags: [],
|
||||
model: '科大讯飞',
|
||||
voice: 'xiaoyan',
|
||||
speed: 50,
|
||||
volume: 50,
|
||||
pitch: 50,
|
||||
sampleRate: '16000',
|
||||
outputFormat: 'wav'
|
||||
})
|
||||
|
||||
const formRef = ref(null)
|
||||
|
||||
const validateArray = (rule, value, callback) => {
|
||||
if (!Array.isArray(value) || value.length === 0) {
|
||||
callback(new Error('请至少添加一条语料'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
}
|
||||
|
||||
const rules = reactive({
|
||||
model: [{ required: true, message: '请选择模型', trigger: 'blur' }],
|
||||
tags: [{ required: true, validator: validateArray, trigger: 'change' }],
|
||||
voice: [{ required: true, message: '请输入音色', trigger: 'blur' }]
|
||||
})
|
||||
|
||||
const dialectOption = ref([
|
||||
{
|
||||
label: '东北话',
|
||||
@ -179,78 +255,34 @@ const emotionOption = ref([
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
const currentSynthesis = ref({
|
||||
id: null,
|
||||
fileSize: null,
|
||||
outputFormat: null,
|
||||
text: '',
|
||||
audioFilePath: ''
|
||||
})
|
||||
const synthesis = async () => {
|
||||
loading.value = true
|
||||
const result = await formRef.value.validate()
|
||||
if (result) {
|
||||
const res = await taskApi(form.value)
|
||||
console.log('res', res)
|
||||
const arr = []
|
||||
const obj = JSON.parse(JSON.stringify(form.value))
|
||||
delete obj.tags
|
||||
form.value.tags.forEach(item => {
|
||||
arr.push({
|
||||
...obj,
|
||||
text: item
|
||||
})
|
||||
})
|
||||
loading.value = true
|
||||
const res = await batchTaskApi(arr)
|
||||
if (res.code === 200) {
|
||||
currentSynthesis.value = {
|
||||
...res.data
|
||||
}
|
||||
emit('refresh')
|
||||
ElMessage.success(res.msg)
|
||||
} else {
|
||||
ElMessage.error(res.msg)
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const regenerate = async () => {
|
||||
const res = await regenerateApi(currentSynthesis.value.id)
|
||||
console.log('res', res)
|
||||
}
|
||||
|
||||
const cancel = () => {
|
||||
formRef.value.resetFields()
|
||||
}
|
||||
|
||||
const cardList = ref([
|
||||
{
|
||||
title: '预计时长',
|
||||
value: '00:06'
|
||||
}, {
|
||||
title: '字符数',
|
||||
value: '21'
|
||||
}, {
|
||||
title: '采样率',
|
||||
value: '48kHz'
|
||||
}, {
|
||||
title: '文件大小',
|
||||
value: '256KB'
|
||||
}
|
||||
])
|
||||
|
||||
// 波形数据
|
||||
const waveform = ref(Array.from({ length: 80 }, () => Math.floor(Math.random() * 60 + 20)))
|
||||
const timer = ref(null)
|
||||
|
||||
// 模拟波形动态变化
|
||||
const updateWaveform = () => {
|
||||
waveform.value = Array.from({ length: 80 }, () => Math.floor(Math.random() * 60 + 20))
|
||||
}
|
||||
|
||||
const audioUrl = ref('https://sf1-cdn-tos.huoshanstatic.com/obj/media-fe/xgplayer_doc_video/music/audio.mp3')
|
||||
|
||||
const audioStatus = (isPlaying) => {
|
||||
if (isPlaying) {
|
||||
timer.value = setInterval(updateWaveform, 1000)
|
||||
} else {
|
||||
if (timer.value) clearInterval(timer.value)
|
||||
cancelAnimationFrame(animationFrame)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timer.value) clearInterval(timer.value)
|
||||
cancelAnimationFrame(animationFrame)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@ -259,25 +291,6 @@ onBeforeUnmount(() => {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
|
||||
.left {
|
||||
flex: 1;
|
||||
background: #fff;
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.right {
|
||||
flex: 1;
|
||||
background: #fff;
|
||||
@ -290,53 +303,44 @@ onBeforeUnmount(() => {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.wave-container {
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
min-height: 140px;
|
||||
position: relative;
|
||||
|
||||
.wave {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
height: 80px;
|
||||
gap: 4px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.audio-container {
|
||||
width: 100%;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.tag {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.card-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
|
||||
.card {
|
||||
border: 1px solid #d3d3d3;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
.btn {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
:deep(.el-form-item--default) {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.left {
|
||||
flex: 1;
|
||||
background: #fff;
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.corpus-container {
|
||||
max-height: 100px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
:deep(.el-form-item--default) {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
|
||||
@ -24,28 +24,28 @@ const stats = [
|
||||
{
|
||||
iconName: 'task',
|
||||
label: '合成任务总数(条)',
|
||||
value: '856,420',
|
||||
value: '13',
|
||||
trend: '+12.4%',
|
||||
trendClass: 'up'
|
||||
},
|
||||
{
|
||||
iconName: 'add',
|
||||
label: '今日新增合成(条)',
|
||||
value: '12,680',
|
||||
value: '3',
|
||||
trend: '+8.7%',
|
||||
trendClass: 'up',
|
||||
},
|
||||
{
|
||||
iconName: 'language',
|
||||
label: '支持语种(种)',
|
||||
value: '12',
|
||||
value: '4',
|
||||
trend: '—',
|
||||
trendClass: 'neutral'
|
||||
},
|
||||
{
|
||||
iconName: 'consuming',
|
||||
label: '可用音色(个)',
|
||||
value: '36',
|
||||
value: '3',
|
||||
trend: '+3.2%',
|
||||
trendClass: 'up'
|
||||
},
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<el-card shadow="never">
|
||||
<div class="taskList-container">
|
||||
<div class="flex-between mb-3">
|
||||
<span class="stat-label">合成任务列表</span>
|
||||
</div>
|
||||
@ -30,9 +30,31 @@
|
||||
</el-table-column>
|
||||
<el-table-column align="center" class-name="small-padding fixed-width" label="操作">
|
||||
<template #default="scope">
|
||||
<el-button link @click="play(scope.rows.audioFilePath)" type="primary" icon="VideoPlay"></el-button>
|
||||
<el-button icon="Delete" link type="primary" @click="handleDelete(scope.row.id)"></el-button>
|
||||
<el-button icon="Download" link type="primary" @click="handleDownload(scope.row)"></el-button>
|
||||
<el-button link @click="play(scope.row.audioFilePath)" type="primary"
|
||||
icon="VideoPlay">播放</el-button>
|
||||
<el-button icon="Delete" link type="primary" @click="handleDelete(scope.row.id)">删除</el-button>
|
||||
|
||||
<el-dropdown>
|
||||
<span class="el-dropdown-link">
|
||||
...
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item>
|
||||
<el-button icon="Download" link type="primary"
|
||||
@click="handleDownload(scope.row)">下载</el-button>
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item>
|
||||
<el-button icon="Collection" link type="primary"
|
||||
@click="handleSave(scope.row)">保存</el-button>
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item>
|
||||
<el-button icon="Refresh" link type="primary"
|
||||
@click="regenerate(scope.row.id)">重新生成</el-button>
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@ -41,15 +63,49 @@
|
||||
<el-pagination background layout="prev, pager, next, jumper" :total="totalTasks" :page-size="10"
|
||||
v-model:current-page="page" small />
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="dialogVisible" title="播放音频" width="600px" :destroy-on-close="true" @close="colseDialog">
|
||||
<div class="wave-container">
|
||||
<div class="wave">
|
||||
<div v-for="(h, index) in waveform" :key="index" :style="{
|
||||
width: '4px',
|
||||
height: h + '%',
|
||||
background: '#3b82f6',
|
||||
borderRadius: '2px',
|
||||
opacity: 0.6 + (h / 100) * 0.4
|
||||
}"></div>
|
||||
</div>
|
||||
<div class="audio-container">
|
||||
<AudioPlayer :src="audioUrl" @audioStatus="audioStatus" />
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- <el-dialog v-model="corpusDialogVisible" title="保存" :append-to-body="true" width="600px" :destroy-on-close="true" @close="colseDialog">
|
||||
<div class="wave-container">
|
||||
<div class="wave">
|
||||
<div v-for="(h, index) in waveform" :key="index" :style="{
|
||||
width: '4px',
|
||||
height: h + '%',
|
||||
background: '#3b82f6',
|
||||
borderRadius: '2px',
|
||||
opacity: 0.6 + (h / 100) * 0.4
|
||||
}"></div>
|
||||
</div>
|
||||
<div class="audio-container">
|
||||
<AudioPlayer :src="audioUrl" @audioStatus="audioStatus" />
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog> -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { taskListApi, deleteTask, downloadWavApi } from '@/api/corpusManger/speech_synthesis'
|
||||
import { usePlayAudio } from '@/hooks/usePlayAudio'
|
||||
import { taskListApi, deleteTask, downloadWavApi, regenerateApi } from '@/api/corpusManger/speech_synthesis'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import Download from '@/plugins/download'
|
||||
import AudioPlayer from './AudioPlayer.vue';
|
||||
|
||||
const search = ref('')
|
||||
const activeTab = ref('')
|
||||
@ -68,7 +124,7 @@ const totalTasks = computed(() => tasks.value.length)
|
||||
|
||||
const changeTab = (key) => {
|
||||
activeTab.value = key
|
||||
getTaskList(key)
|
||||
getTaskList()
|
||||
}
|
||||
|
||||
const statusMap = { '2': 'success', '1': 'warning', '0': 'info', '3': 'danger' }
|
||||
@ -89,8 +145,8 @@ const getTaskList = async (status = null) => {
|
||||
pageNum: page.value,
|
||||
pageSize: 10,
|
||||
}
|
||||
if (status) {
|
||||
obj.status = status
|
||||
if (activeTab.value) {
|
||||
obj.status = activeTab.value
|
||||
}
|
||||
const res = await taskListApi(obj)
|
||||
if (res.code == 0) {
|
||||
@ -99,9 +155,39 @@ const getTaskList = async (status = null) => {
|
||||
}
|
||||
}
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
|
||||
// 波形数据
|
||||
const waveform = ref(Array.from({ length: 60 }, () => Math.floor(Math.random() * 60 + 20)))
|
||||
const timer = ref(null)
|
||||
|
||||
// 模拟波形动态变化
|
||||
const updateWaveform = () => {
|
||||
waveform.value = Array.from({ length: 60 }, () => Math.floor(Math.random() * 60 + 20))
|
||||
}
|
||||
|
||||
const audioUrl = ref('https://sf1-cdn-tos.huoshanstatic.com/obj/media-fe/xgplayer_doc_video/music/audio.mp3')
|
||||
|
||||
const audioStatus = (isPlaying) => {
|
||||
if (isPlaying) {
|
||||
timer.value = setInterval(updateWaveform, 100)
|
||||
} else {
|
||||
if (timer.value) clearInterval(timer.value)
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timer.value) clearInterval(timer.value)
|
||||
})
|
||||
|
||||
const play = (url) => {
|
||||
const { playAudio } = usePlayAudio()
|
||||
playAudio(url)
|
||||
dialogVisible.value = true
|
||||
audioUrl.value = import.meta.env.VITE_MINIO_URL + url
|
||||
}
|
||||
|
||||
const colseDialog = () => {
|
||||
dialogVisible.value = false
|
||||
if (timer.value) clearInterval(timer.value)
|
||||
}
|
||||
|
||||
const handleDelete = (id) => {
|
||||
@ -134,12 +220,34 @@ const handleDownload = async (row) => {
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
|
||||
}
|
||||
|
||||
const regenerate = async (id) => {
|
||||
const res = await regenerateApi(id)
|
||||
if (res.code === 200) {
|
||||
getTaskList()
|
||||
ElMessage.success(res.msg)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getTaskList()
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
getTaskList
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.taskList-container {
|
||||
background: #fff;
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.flex-between {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -177,4 +285,25 @@ onMounted(() => {
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
|
||||
.wave-container {
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
min-height: 140px;
|
||||
position: relative;
|
||||
|
||||
.wave {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
height: 80px;
|
||||
gap: 4px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.audio-container {
|
||||
width: 100%;
|
||||
margin: 10px 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -7,17 +7,28 @@
|
||||
|
||||
<!-- 主工作区 -->
|
||||
<div class="mainWorkspace">
|
||||
<MainWorkspace />
|
||||
<MainWorkspace @refresh="refresh" />
|
||||
</div>
|
||||
|
||||
<!-- 任务列表 -->
|
||||
<TaskList />
|
||||
<TaskList ref="taskListRef" />
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import StatCards from './components/StatCards.vue'
|
||||
import MainWorkspace from './components/MainWorkspace.vue'
|
||||
import TaskList from './components/TaskList.vue'
|
||||
|
||||
import autofit from 'autofit.js'
|
||||
autofit.init({
|
||||
el: "#app",
|
||||
})
|
||||
|
||||
const taskListRef = ref()
|
||||
const refresh = () => {
|
||||
taskListRef.value.getTaskList()
|
||||
}
|
||||
</script>
|
||||
<script setup></script>
|
||||
<style lang="scss" scoped>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user