Files
admin-ui/src/views/digitalHuman/audioAssets/component/ttsDialog.vue

525 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<el-dialog
v-model="visible"
title="添加音频"
width="700px"
:close-on-click-modal="false"
@close="handleClose"
>
<el-tabs v-model="activeTab" class="audio-tabs">
<el-tab-pane label="上传音频" name="upload">
<el-form ref="uploadFormRef" :model="uploadForm" :rules="uploadRules" label-width="100px">
<el-form-item label="音频名称" prop="name">
<el-input v-model="uploadForm.name" placeholder="请输入音频名称" maxlength="50" show-word-limit />
</el-form-item>
<el-form-item label="音色类型" prop="voiceType">
<el-select v-model="uploadForm.voiceType" placeholder="请选择音色类型" style="width: 100%">
<el-option label="男声" value="male" />
<el-option label="女声" value="female" />
<el-option label="童声" value="child" />
</el-select>
</el-form-item>
<el-form-item label="上传文件" prop="file">
<el-upload
ref="uploadRef"
class="audio-uploader"
drag
action="#"
:auto-upload="false"
:limit="1"
accept=".mp3,.wav,.pcm,.flac"
:on-change="handleFileChange"
:on-exceed="handleExceed"
>
<el-icon class="el-icon--upload"><ele-UploadFilled /></el-icon>
<div class="el-upload__text">将文件拖到此处<em>点击上传</em></div>
<template #tip>
<div class="el-upload__tip">支持 mp3wavpcmflac 格式文件大小不超过 50MB</div>
</template>
</el-upload>
</el-form-item>
<!-- 上传预览 -->
<div v-if="uploadPreview.show" class="preview-section">
<div class="preview-header">
<span class="preview-title">文件信息</span>
<el-tag type="success" size="small">已选择</el-tag>
</div>
<div class="preview-info">
<span>文件名: {{ uploadPreview.fileName }}</span>
<span>文件大小: {{ formatFileSize(uploadPreview.fileSize) }}</span>
</div>
</div>
</el-form>
</el-tab-pane>
<el-tab-pane label="文本转语音" name="tts">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="音频名称" prop="name">
<el-input v-model="form.name" placeholder="请输入音频名称" maxlength="50" show-word-limit />
</el-form-item>
<el-form-item label="文本内容" prop="text">
<el-input
v-model="form.text"
type="textarea"
:rows="5"
placeholder="请输入要转换的文本内容"
maxlength="500"
show-word-limit
/>
</el-form-item>
<el-form-item label="音色选择" prop="voice">
<el-select v-model="form.voice" placeholder="请选择音色" style="width: 100%">
<el-option-group label="男声">
<el-option label="商务男声" value="male_business" />
<el-option label="磁性男声" value="male_magnetic" />
<el-option label="新闻男声" value="male_news" />
</el-option-group>
<el-option-group label="女声">
<el-option label="甜美女声" value="female_sweet" />
<el-option label="知性女声" value="female_intellectual" />
<el-option label="温柔女声" value="female_gentle" />
</el-option-group>
<el-option-group label="童声">
<el-option label="活泼童声" value="child_lively" />
<el-option label="可爱童声" value="child_cute" />
</el-option-group>
</el-select>
</el-form-item>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="语速">
<el-slider v-model="form.speed" :min="0.5" :max="2" :step="0.1" :format-tooltip="(val: number) => val + 'x'" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="音量">
<el-slider v-model="form.volume" :min="0" :max="100" :format-tooltip="(val: number) => val + '%'" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="音调">
<el-slider v-model="form.pitch" :min="-12" :max="12" :step="1" :format-tooltip="(val: number) => (val > 0 ? '+' : '') + val" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="采样率">
<el-select v-model="form.sampleRate" style="width: 100%">
<el-option label="16000Hz" :value="16000" />
<el-option label="22050Hz" :value="22050" />
<el-option label="44100Hz" :value="44100" />
<el-option label="48000Hz" :value="48000" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="输出格式">
<el-radio-group v-model="form.format">
<el-radio label="mp3">MP3</el-radio>
<el-radio label="wav">WAV</el-radio>
<el-radio label="pcm">PCM</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<!-- TTS预览区域 -->
<div v-if="previewGenerated" class="preview-section">
<div class="preview-header">
<span class="preview-title">预览</span>
<el-tag type="success" size="small">已生成</el-tag>
</div>
<div class="preview-player">
<el-button
:type="isPlaying ? 'danger' : 'primary'"
circle
@click="togglePreview"
>
<el-icon>
<ele-VideoPlay v-if="!isPlaying" />
<ele-VideoPause v-else />
</el-icon>
</el-button>
<el-progress
:percentage="playProgress"
:show-text="false"
style="flex: 1; margin: 0 15px"
/>
<span class="duration">{{ formatDuration(previewDuration) }}</span>
</div>
<div class="preview-info">
<span>文件大小: {{ formatFileSize(previewFileSize) }}</span>
<span>时长: {{ formatDuration(previewDuration) }}</span>
<span>采样率: {{ form.sampleRate }}Hz</span>
</div>
</div>
</el-tab-pane>
</el-tabs>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<template v-if="activeTab === 'upload'">
<el-button type="primary" :loading="uploading" :disabled="!uploadPreview.show" @click="handleUpload">
<el-icon><ele-Upload /></el-icon>
{{ uploading ? '上传中...' : '上传音频' }}
</el-button>
</template>
<template v-else>
<el-button type="warning" :loading="generating" @click="handleGenerate">
<el-icon><ele-Headset /></el-icon>
{{ generating ? '生成中...' : '生成预览' }}
</el-button>
<el-button type="primary" :disabled="!previewGenerated" :loading="saving" @click="handleSave">
<el-icon><ele-Check /></el-icon>
保存音频
</el-button>
</template>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, watch } from 'vue';
import { ElMessage } from 'element-plus';
import type { FormInstance, FormRules, UploadInstance, UploadFile, UploadRawFile } from 'element-plus';
const emit = defineEmits(['success']);
const visible = ref(false);
const activeTab = ref('upload');
const formRef = ref<FormInstance>();
const uploadFormRef = ref<FormInstance>();
const uploadRef = ref<UploadInstance>();
const generating = ref(false);
const saving = ref(false);
const uploading = ref(false);
const previewGenerated = ref(false);
const isPlaying = ref(false);
const playProgress = ref(0);
const previewDuration = ref(0);
const previewFileSize = ref(0);
let progressTimer: ReturnType<typeof setInterval> | null = null;
// 上传表单
const uploadForm = reactive({
name: '',
voiceType: '',
file: null as File | null,
});
// 上传预览信息
const uploadPreview = reactive({
show: false,
fileName: '',
fileSize: 0,
});
// TTS 表单
const form = reactive({
name: '',
text: '',
voice: 'female_sweet',
speed: 1.0,
volume: 80,
pitch: 0,
sampleRate: 44100,
format: 'mp3',
});
// 上传表单校验规则
const uploadRules = reactive<FormRules>({
name: [{ required: true, message: '请输入音频名称', trigger: 'blur' }],
voiceType: [{ required: true, message: '请选择音色类型', trigger: 'change' }],
});
// TTS 表单校验规则
const rules = reactive<FormRules>({
name: [{ required: true, message: '请输入音频名称', trigger: 'blur' }],
text: [
{ required: true, message: '请输入文本内容', trigger: 'blur' },
{ min: 1, max: 500, message: '文本内容长度在1-500个字符之间', trigger: 'blur' },
],
voice: [{ required: true, message: '请选择音色', trigger: 'change' }],
});
// 打开弹窗
const openDialog = () => {
visible.value = true;
resetForm();
};
// 重置表单
const resetForm = () => {
activeTab.value = 'upload';
// 重置上传表单
uploadForm.name = '';
uploadForm.voiceType = '';
uploadForm.file = null;
uploadPreview.show = false;
uploadPreview.fileName = '';
uploadPreview.fileSize = 0;
uploadRef.value?.clearFiles();
// 重置 TTS 表单
form.name = '';
form.text = '';
form.voice = 'female_sweet';
form.speed = 1.0;
form.volume = 80;
form.pitch = 0;
form.sampleRate = 44100;
form.format = 'mp3';
previewGenerated.value = false;
isPlaying.value = false;
playProgress.value = 0;
previewDuration.value = 0;
previewFileSize.value = 0;
};
// 文件选择变化
const handleFileChange = (file: UploadFile) => {
if (file.raw) {
const maxSize = 50 * 1024 * 1024; // 50MB
if (file.raw.size > maxSize) {
ElMessage.error('文件大小不能超过 50MB');
uploadRef.value?.clearFiles();
return;
}
uploadForm.file = file.raw;
uploadPreview.show = true;
uploadPreview.fileName = file.name;
uploadPreview.fileSize = file.raw.size;
// 自动填充音频名称
if (!uploadForm.name) {
uploadForm.name = file.name.replace(/\.[^/.]+$/, '');
}
}
};
// 文件超出限制
const handleExceed = () => {
ElMessage.warning('只能上传一个文件,请先删除已选文件');
};
// 上传音频
const handleUpload = async () => {
if (!uploadFormRef.value) return;
await uploadFormRef.value.validate((valid) => {
if (!valid) return;
if (!uploadForm.file) {
ElMessage.warning('请选择要上传的音频文件');
return;
}
uploading.value = true;
// 模拟上传过程
setTimeout(() => {
uploading.value = false;
ElMessage.success('音频上传成功');
emit('success', {
name: uploadForm.name,
voiceType: uploadForm.voiceType,
duration: Math.floor(Math.random() * 180) + 30,
fileSize: uploadPreview.fileSize,
sampleRate: 44100,
format: uploadPreview.fileName.split('.').pop(),
});
handleClose();
}, 1500);
});
};
// 关闭弹窗
const handleClose = () => {
stopPreview();
visible.value = false;
};
// 生成预览
const handleGenerate = async () => {
if (!formRef.value) return;
await formRef.value.validate((valid) => {
if (!valid) return;
generating.value = true;
// 模拟生成过程
setTimeout(() => {
// 根据文本长度估算时长假设每秒5个字
const textLength = form.text.length;
const baseDuration = Math.ceil(textLength / 5);
previewDuration.value = Math.max(3, Math.round(baseDuration / form.speed));
// 估算文件大小
const bitrate = form.format === 'wav' ? 1411 : form.format === 'mp3' ? 128 : 256;
previewFileSize.value = Math.round((previewDuration.value * bitrate * 1000) / 8);
previewGenerated.value = true;
generating.value = false;
ElMessage.success('语音生成成功,请试听预览');
}, 1500);
});
};
// 播放/暂停预览
const togglePreview = () => {
if (isPlaying.value) {
stopPreview();
} else {
startPreview();
}
};
// 开始播放
const startPreview = () => {
isPlaying.value = true;
playProgress.value = 0;
const totalSteps = previewDuration.value * 10;
let currentStep = 0;
progressTimer = setInterval(() => {
currentStep++;
playProgress.value = Math.round((currentStep / totalSteps) * 100);
if (playProgress.value >= 100) {
stopPreview();
}
}, 100);
};
// 停止播放
const stopPreview = () => {
isPlaying.value = false;
playProgress.value = 0;
if (progressTimer) {
clearInterval(progressTimer);
progressTimer = null;
}
};
// 保存音频
const handleSave = async () => {
saving.value = true;
// 模拟保存过程
setTimeout(() => {
saving.value = false;
ElMessage.success('音频保存成功');
emit('success', {
name: form.name,
voiceType: form.voice.split('_')[0],
duration: previewDuration.value,
fileSize: previewFileSize.value,
sampleRate: form.sampleRate,
format: form.format,
});
handleClose();
}, 1000);
};
// 格式化时长
const formatDuration = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
// 格式化文件大小
const formatFileSize = (bytes: number) => {
if (bytes < 1024) return bytes + 'B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + 'KB';
return (bytes / 1024 / 1024).toFixed(1) + 'MB';
};
// 监听表单变化,重置预览状态
watch(
() => [form.text, form.voice, form.speed, form.pitch],
() => {
if (previewGenerated.value) {
previewGenerated.value = false;
stopPreview();
}
}
);
defineExpose({
openDialog,
});
</script>
<style scoped lang="scss">
.audio-tabs {
:deep(.el-tabs__content) {
padding: 10px 0;
}
}
.audio-uploader {
width: 100%;
:deep(.el-upload-dragger) {
width: 100%;
}
}
.preview-section {
margin-top: 20px;
padding: 16px;
background: #f5f7fa;
border-radius: 8px;
border: 1px solid #ebeef5;
.preview-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 15px;
.preview-title {
font-size: 14px;
font-weight: 600;
color: #303133;
}
}
.preview-player {
display: flex;
align-items: center;
margin-bottom: 12px;
.duration {
font-size: 12px;
color: #909399;
min-width: 40px;
}
}
.preview-info {
display: flex;
gap: 20px;
font-size: 12px;
color: #909399;
}
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
</style>