feat: 合成语音

This commit is contained in:
zhanghao 2026-07-07 14:12:57 +08:00
parent 36fad9c1ca
commit af2f9398b7
16 changed files with 651 additions and 254 deletions

View File

@ -11,4 +11,6 @@ VITE_API_URL = http://192.168.1.101:13080
VITE_WS_URL = ws://192.168.1.101:13080/ws VITE_WS_URL = ws://192.168.1.101:13080/ws
# explanation # explanation
VITE_INSPECTION_TYPE = "inspection" VITE_INSPECTION_TYPE = "inspection"
VITE_MINIO_URL = "http://192.168.28.10:9000/cmvr-iot/"

View File

@ -17,4 +17,6 @@ VITE_API_URL = http://192.168.28.10:13080
VITE_WS_URL = ws://192.168.0.201:13080/ws VITE_WS_URL = ws://192.168.0.201:13080/ws
# explanation # explanation
VITE_INSPECTION_TYPE = "inspection" VITE_INSPECTION_TYPE = "inspection"
VITE_MINIO_URL = "http://192.168.28.10:9000/cmvr-iot/"

View File

@ -19,6 +19,7 @@
"@vueup/vue-quill": "1.2.0", "@vueup/vue-quill": "1.2.0",
"@vueuse/core": "10.11.0", "@vueuse/core": "10.11.0",
"ace-builds": "^1.43.0", "ace-builds": "^1.43.0",
"autofit.js": "^3.2.8",
"axios": "0.28.1", "axios": "0.28.1",
"clipboard": "2.0.11", "clipboard": "2.0.11",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",

View File

@ -43,4 +43,20 @@ export function regenerateApi(id) {
url: `/tts/task/regenerate/${id}`, url: `/tts/task/regenerate/${id}`,
method: 'post' 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
})
} }

View File

@ -1,7 +1,8 @@
#app { #app {
.main-container { .main-container {
min-height: 100%; // min-height: 100%;
height: 100%;
transition: margin-left .28s; transition: margin-left .28s;
margin-left: $base-sidebar-width; margin-left: $base-sidebar-width;
position: relative; position: relative;

View 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, // 是否开启快捷键模式
}

View 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>

View File

@ -37,7 +37,7 @@ function addIframe() {
@import "@/assets/styles/variables.module"; @import "@/assets/styles/variables.module";
.app-main { .app-main {
/* 50= navbar 50 */ /* 50= navbar 50 */
height: calc(100vh - 84px); height: calc(100% - 84px);
padding: 20px; padding: 20px;
width: 100%; width: 100%;
position: relative; position: relative;
@ -54,7 +54,7 @@ function addIframe() {
.hasTagsView { .hasTagsView {
.app-main { .app-main {
/* 84 = navbar + tags-view = 50 + 34 */ /* 84 = navbar + tags-view = 50 + 34 */
height: calc(100vh - 84px); height: calc(100% - 84px);
} }
.fixed-header + .app-main { .fixed-header + .app-main {

View File

@ -1,19 +1,13 @@
<template> <template>
<el-card shadow="never" class="corpus-list"> <div class="corpus-list">
<!-- 工具栏 --> <!-- 工具栏 -->
<div class="list-toolbar"> <div class="list-toolbar">
<div class="toolbar-left"> <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>
<div class="toolbar-right"> <div class="toolbar-right">
<el-tag <el-tag v-for="tab in tabs" :key="tab.key" :type="activeTab === tab.key ? '' : 'info'"
v-for="tab in tabs" :effect="activeTab === tab.key ? 'dark' : 'plain'" style="cursor:pointer;" @click="activeTab = tab.key">
:key="tab.key"
:type="activeTab === tab.key ? '' : 'info'"
:effect="activeTab === tab.key ? 'dark' : 'plain'"
style="cursor:pointer;"
@click="activeTab = tab.key"
>
{{ tab.label }} {{ tab.label }}
<span class="tab-count">{{ tab.count }}</span> <span class="tab-count">{{ tab.count }}</span>
</el-tag> </el-tag>
@ -21,7 +15,7 @@
</div> </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="id" label="语料编号" width="180" />
<el-table-column prop="text" label="文本内容" min-width="140" show-overflow-tooltip /> <el-table-column prop="text" label="文本内容" min-width="140" show-overflow-tooltip />
<el-table-column prop="category" label="功能类别" width="150" show-overflow-tooltip /> <el-table-column prop="category" label="功能类别" width="150" show-overflow-tooltip />
@ -47,27 +41,151 @@
<!-- 分页 --> <!-- 分页 -->
<div class="pagination-wrapper"> <div class="pagination-wrapper">
<span class="text-muted text-sm"> {{ total }} </span> <span class="text-muted text-sm"> {{ total }} </span>
<el-pagination <el-pagination background layout="prev, pager, next, jumper" :total="total" :page-size="10"
background v-model:current-page="page" small />
layout="prev, pager, next, jumper"
:total="total"
:page-size="10"
v-model:current-page="page"
small
/>
</div> </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> </template>
<script setup> <script setup>
import { ref, computed } from 'vue' 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 activeTab = ref('all')
const page = ref(1) 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 = [ const tabs = [
{ key: 'all', label: '全部语料', count: '1,245,678' }, { key: 'all', label: '全部语料', count: '1,245,678' },
{ key: 'pending', label: '待审核', count: '45,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' } { id: 'CORP_20240517_0008', text: '关闭所有车窗', category: '控制指令-车窗控制', lang: '中文', dialect: '普通话', emotion: '中性', voice: '男声', status: '已通过', updateTime: '2024-05-17 10:06:20' }
]) ])
const filteredData = computed(() => { const total = computed(() => corpusData.value.length)
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 statusType = (status) => { const statusType = (status) => {
if (status === '已通过') return 'success' if (status === '已通过') return 'success'
if (status === '待审核') return 'warning' if (status === '待审核') return 'warning'
return 'info' return 'info'
} }
const handleDetail = (row) => {
emit('view-detail', row)
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.corpus-list { .corpus-list {
background: #fff;
padding: 20px;
border-radius: 12px;
.list-toolbar { .list-toolbar {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
margin-bottom: 16px; margin-bottom: 16px;
.toolbar-left { .toolbar-left {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
flex-wrap: wrap; flex-wrap: wrap;
} }
.toolbar-right { .toolbar-right {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
flex-wrap: wrap; flex-wrap: wrap;
.tab-count { .tab-count {
margin-left: 4px; margin-left: 4px;
color: #94a3b8; color: #94a3b8;
@ -142,13 +245,20 @@ const handleDetail = (row) => {
} }
} }
} }
.pagination-wrapper { .pagination-wrapper {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-top: 16px; margin-top: 16px;
} }
.text-muted { color: #94a3b8; }
.text-sm { font-size: 13px; } .text-muted {
color: #94a3b8;
}
.text-sm {
font-size: 13px;
}
} }
</style> </style>

View File

@ -1,20 +1,77 @@
<template> <template>
<div class="main-workspace"> <div class="main-workspace">
<div class="workspace-container"> <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 class="title">
文本与参数配置 文本与参数配置
</div> </div>
<el-form ref="formRef" :rules="rules" :model="form" label-position="top" label-width="auto"> <el-form ref="formRef" :rules="rules" :model="form" label-position="top" label-width="auto">
<el-row> <el-row>
<el-col :span="24"> <el-form-item label="待合成语料" prop="tags">
<el-form-item label="" prop="text"> <el-tag v-for="tag in form.tags" :key="tag.name" closable @close="handleClose(tag)" class="tag">
<el-input type="textarea" :rows="3" v-model="form.text" placeholder="请输入待合成文本" /> {{ tag }}
</el-form-item> </el-tag>
</el-col> </el-form-item>
</el-row> </el-row>
<el-row :gutter="20"> <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-col :span="8">
<el-form-item label="音色" prop="voice"> <el-form-item label="音色" prop="voice">
<el-select v-model="form.voice" placeholder="音色"> <el-select v-model="form.voice" placeholder="音色">
@ -29,9 +86,7 @@
</el-col> </el-col>
<el-col :span="8"> <el-col :span="8">
<el-form-item label="语速" prop="speed"> <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-slider v-model="form.speed" :min="1" :max="100" :step="1" />
</el-select>
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="8"> <el-col :span="8">
@ -41,9 +96,9 @@
</el-col> </el-col>
<el-col :span="8"> <el-col :span="8">
<el-form-item label="采样率"> <el-form-item label="采样率">
<el-select v-model="form.auf" placeholder="采样率"> <el-select v-model="form.sampleRate" placeholder="采样率">
<el-option label="8kHz" value="8" /> <el-option label="8kHz" value="8000" />
<el-option label="16kHz" value="16" /> <el-option label="16kHz" value="16000" />
</el-select> </el-select>
</el-form-item> </el-form-item>
</el-col> </el-col>
@ -51,14 +106,13 @@
<el-form-item label="输出格式"> <el-form-item label="输出格式">
<el-select v-model="form.outputFormat" placeholder="输出格式"> <el-select v-model="form.outputFormat" placeholder="输出格式">
<el-option label="WAV" value="wav" /> <el-option label="WAV" value="wav" />
<el-option label="男声-沉稳" value="male-calm" />
</el-select> </el-select>
</el-form-item> </el-form-item>
</el-col> </el-col>
</el-row> </el-row>
<el-row style="margin-top: 20px;" :gutter="20"> <el-row style="margin-top: 20px;" :gutter="20">
<el-col :span="12"> <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> @click="synthesis">开始合成</el-button>
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
@ -68,38 +122,6 @@
</el-row> </el-row>
</el-form> </el-form>
</div> </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>
</div> </div>
</template> </template>
@ -107,28 +129,24 @@
<script setup> <script setup>
import { ref, reactive, onMounted, onBeforeUnmount } from 'vue' import { ref, reactive, onMounted, onBeforeUnmount } from 'vue'
import { Delete, VideoPlay } from '@element-plus/icons-vue' import { Delete, VideoPlay } from '@element-plus/icons-vue'
import { taskApi, regenerateApi } from '@/api/corpusManger/speech_synthesis' import { batchTaskApi, regenerateApi, generalizationApi } from '@/api/corpusManger/speech_synthesis'
import AudioPlayer from './AudioPlayer.vue'; import { ElMessage } from 'element-plus'
const emit = defineEmits(['refresh'])
const form = ref({ const generalizationFormRef = ref()
const generalizationForm = ref({
text: '', text: '',
voice: 'xiaoyan', language: '普通话',
speed: 50, count: 1
volume: 50,
pitch: 50,
auf: '16',
outputFormat: 'wav'
}) })
const generalizationRules = ref({
const formRef = ref(null)
const rules = reactive({
text: [{ required: true, message: '请输入合成文本', trigger: 'blur' }], 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: '中文(普通话)', label: '中文(普通话)',
value: '普通话' value: '普通话'
@ -141,9 +159,67 @@ const langOption = ref([
}, { }, {
label: '四川话', label: '四川话',
value: '四川话' 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([ const dialectOption = ref([
{ {
label: '东北话', label: '东北话',
@ -179,78 +255,34 @@ const emotionOption = ref([
const loading = ref(false) const loading = ref(false)
const currentSynthesis = ref({
id: null,
fileSize: null,
outputFormat: null,
text: '',
audioFilePath: ''
})
const synthesis = async () => { const synthesis = async () => {
loading.value = true
const result = await formRef.value.validate() const result = await formRef.value.validate()
if (result) { if (result) {
const res = await taskApi(form.value) const arr = []
console.log('res', res) 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) { if (res.code === 200) {
currentSynthesis.value = { emit('refresh')
...res.data ElMessage.success(res.msg)
} } else {
ElMessage.error(res.msg)
} }
loading.value = false loading.value = false
} }
} }
const regenerate = async () => {
const res = await regenerateApi(currentSynthesis.value.id)
console.log('res', res)
}
const cancel = () => { const cancel = () => {
formRef.value.resetFields() 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> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@ -259,25 +291,6 @@ onBeforeUnmount(() => {
display: flex; display: flex;
gap: 24px; 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 { .right {
flex: 1; flex: 1;
background: #fff; background: #fff;
@ -290,53 +303,44 @@ onBeforeUnmount(() => {
margin-bottom: 10px; margin-bottom: 10px;
} }
.wave-container { .tag {
border-radius: 12px; margin-right: 8px;
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;
}
} }
.card-container { .btn {
display: grid; width: 100%;
grid-template-columns: repeat(4, 1fr); height: 32px;
gap: 16px; font-size: 18px;
.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;
}
}
} }
: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 { .btn {
width: 100%; width: 100%;
height: 32px; height: 32px;

View File

@ -24,28 +24,28 @@ const stats = [
{ {
iconName: 'task', iconName: 'task',
label: '合成任务总数(条)', label: '合成任务总数(条)',
value: '856,420', value: '13',
trend: '+12.4%', trend: '+12.4%',
trendClass: 'up' trendClass: 'up'
}, },
{ {
iconName: 'add', iconName: 'add',
label: '今日新增合成(条)', label: '今日新增合成(条)',
value: '12,680', value: '3',
trend: '+8.7%', trend: '+8.7%',
trendClass: 'up', trendClass: 'up',
}, },
{ {
iconName: 'language', iconName: 'language',
label: '支持语种(种)', label: '支持语种(种)',
value: '12', value: '4',
trend: '—', trend: '—',
trendClass: 'neutral' trendClass: 'neutral'
}, },
{ {
iconName: 'consuming', iconName: 'consuming',
label: '可用音色(个)', label: '可用音色(个)',
value: '36', value: '3',
trend: '+3.2%', trend: '+3.2%',
trendClass: 'up' trendClass: 'up'
}, },

View File

@ -1,5 +1,5 @@
<template> <template>
<el-card shadow="never"> <div class="taskList-container">
<div class="flex-between mb-3"> <div class="flex-between mb-3">
<span class="stat-label">合成任务列表</span> <span class="stat-label">合成任务列表</span>
</div> </div>
@ -30,9 +30,31 @@
</el-table-column> </el-table-column>
<el-table-column align="center" class-name="small-padding fixed-width" label="操作"> <el-table-column align="center" class-name="small-padding fixed-width" label="操作">
<template #default="scope"> <template #default="scope">
<el-button link @click="play(scope.rows.audioFilePath)" type="primary" icon="VideoPlay"></el-button> <el-button link @click="play(scope.row.audioFilePath)" type="primary"
<el-button icon="Delete" link type="primary" @click="handleDelete(scope.row.id)"></el-button> icon="VideoPlay">播放</el-button>
<el-button icon="Download" link type="primary" @click="handleDownload(scope.row)"></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> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@ -41,15 +63,49 @@
<el-pagination background layout="prev, pager, next, jumper" :total="totalTasks" :page-size="10" <el-pagination background layout="prev, pager, next, jumper" :total="totalTasks" :page-size="10"
v-model:current-page="page" small /> v-model:current-page="page" small />
</div> </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> </template>
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { taskListApi, deleteTask, downloadWavApi } from '@/api/corpusManger/speech_synthesis' import { taskListApi, deleteTask, downloadWavApi, regenerateApi } from '@/api/corpusManger/speech_synthesis'
import { usePlayAudio } from '@/hooks/usePlayAudio'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import Download from '@/plugins/download' import Download from '@/plugins/download'
import AudioPlayer from './AudioPlayer.vue';
const search = ref('') const search = ref('')
const activeTab = ref('') const activeTab = ref('')
@ -68,7 +124,7 @@ const totalTasks = computed(() => tasks.value.length)
const changeTab = (key) => { const changeTab = (key) => {
activeTab.value = key activeTab.value = key
getTaskList(key) getTaskList()
} }
const statusMap = { '2': 'success', '1': 'warning', '0': 'info', '3': 'danger' } const statusMap = { '2': 'success', '1': 'warning', '0': 'info', '3': 'danger' }
@ -89,8 +145,8 @@ const getTaskList = async (status = null) => {
pageNum: page.value, pageNum: page.value,
pageSize: 10, pageSize: 10,
} }
if (status) { if (activeTab.value) {
obj.status = status obj.status = activeTab.value
} }
const res = await taskListApi(obj) const res = await taskListApi(obj)
if (res.code == 0) { 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 play = (url) => {
const { playAudio } = usePlayAudio() dialogVisible.value = true
playAudio(url) audioUrl.value = import.meta.env.VITE_MINIO_URL + url
}
const colseDialog = () => {
dialogVisible.value = false
if (timer.value) clearInterval(timer.value)
} }
const handleDelete = (id) => { const handleDelete = (id) => {
@ -134,12 +220,34 @@ const handleDownload = async (row) => {
URL.revokeObjectURL(blobUrl) URL.revokeObjectURL(blobUrl)
} }
const handleSave = () => {
}
const regenerate = async (id) => {
const res = await regenerateApi(id)
if (res.code === 200) {
getTaskList()
ElMessage.success(res.msg)
}
}
onMounted(() => { onMounted(() => {
getTaskList() getTaskList()
}) })
defineExpose({
getTaskList
})
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.taskList-container {
background: #fff;
padding: 20px;
border-radius: 12px;
}
.flex-between { .flex-between {
display: flex; display: flex;
align-items: center; align-items: center;
@ -177,4 +285,25 @@ onMounted(() => {
color: #64748b; color: #64748b;
font-weight: 500; 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> </style>

View File

@ -7,17 +7,28 @@
<!-- 主工作区 --> <!-- 主工作区 -->
<div class="mainWorkspace"> <div class="mainWorkspace">
<MainWorkspace /> <MainWorkspace @refresh="refresh" />
</div> </div>
<!-- 任务列表 --> <!-- 任务列表 -->
<TaskList /> <TaskList ref="taskListRef" />
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref } from 'vue'
import StatCards from './components/StatCards.vue' import StatCards from './components/StatCards.vue'
import MainWorkspace from './components/MainWorkspace.vue' import MainWorkspace from './components/MainWorkspace.vue'
import TaskList from './components/TaskList.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>
<script setup></script> <script setup></script>
<style lang="scss" scoped> <style lang="scss" scoped>