update:智控台音色克隆

This commit is contained in:
hrz
2025-10-07 23:54:42 +08:00
parent bef7f79f40
commit 3249970b7b
42 changed files with 4140 additions and 531 deletions
@@ -1,11 +1,13 @@
<template>
<el-dialog :title="title" :visible.sync="dialogVisible" :close-on-click-modal="false" @close="handleClose" @open="handleOpen">
<el-dialog :title="title" :visible.sync="dialogVisible" :close-on-click-modal="false" @close="handleClose"
@open="handleOpen">
<el-form ref="form" :model="form" :rules="rules" label-width="auto">
<el-form-item :label="$t('firmwareDialog.firmwareName')" prop="firmwareName">
<el-input v-model="form.firmwareName" :placeholder="$t('firmwareDialog.firmwareNamePlaceholder')"></el-input>
</el-form-item>
<el-form-item :label="$t('firmwareDialog.firmwareType')" prop="type">
<el-select v-model="form.type" :placeholder="$t('firmwareDialog.firmwareTypePlaceholder')" style="width: 100%;" filterable :disabled="isTypeDisabled">
<el-select v-model="form.type" :placeholder="$t('firmwareDialog.firmwareTypePlaceholder')" style="width: 100%;"
filterable :disabled="isTypeDisabled">
<el-option v-for="item in firmwareTypes" :key="item.key" :label="item.name" :value="item.key"></el-option>
</el-select>
</el-form-item>
@@ -14,7 +16,7 @@
</el-form-item>
<el-form-item :label="$t('firmwareDialog.firmwareFile')" prop="firmwarePath">
<el-upload ref="upload" class="upload-demo" action="#" :http-request="handleUpload"
:before-upload="beforeUpload" :accept="'.bin,.apk'" :limit="1" :multiple="false" :auto-upload="true"
:before-upload="beforeUpload" :accept="'.bin,.apk,.wav'" :limit="1" :multiple="false" :auto-upload="true"
:on-remove="handleRemove">
<el-button size="small" type="primary">{{ $t('firmwareDialog.clickUpload') }}</el-button>
<div slot="tip" class="el-upload__tip">{{ $t('firmwareDialog.uploadTip') }}</div>
@@ -26,7 +28,8 @@
</div>
</el-form-item>
<el-form-item :label="$t('firmwareDialog.remark')" prop="remark">
<el-input type="textarea" v-model="form.remark" :placeholder="$t('firmwareDialog.remarkPlaceholder')"></el-input>
<el-input type="textarea" v-model="form.remark"
:placeholder="$t('firmwareDialog.remarkPlaceholder')"></el-input>
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
@@ -128,13 +131,13 @@ export default {
const isValidType = ['.bin', '.apk'].some(ext => file.name.toLowerCase().endsWith(ext))
if (!isValidType) {
this.$message.error(this.$t('firmwareDialog.invalidFileType'))
return false
}
if (!isValidSize) {
this.$message.error(this.$t('firmwareDialog.invalidFileSize'))
return false
}
this.$message.error(this.$t('firmwareDialog.invalidFileType'))
return false
}
if (!isValidSize) {
this.$message.error(this.$t('firmwareDialog.invalidFileSize'))
return false
}
return true
},
handleUpload(options) {
+138 -86
View File
@@ -16,6 +16,34 @@
:style="{ filter: $route.path === '/home' || $route.path === '/role-config' || $route.path === '/device-management' ? 'brightness(0) invert(1)' : 'None' }" />
<span class="nav-text">{{ $t('header.smartManagement') }}</span>
</div>
<!-- 普通用户显示音色克隆 -->
<div v-if="!isSuperAdmin" class="equipment-management"
:class="{ 'active-tab': $route.path === '/voice-clone-management' }" @click="goVoiceCloneManagement">
<img loading="lazy" alt="" src="@/assets/header/voice.png"
:style="{ filter: $route.path === '/voice-clone-management' ? 'brightness(0) invert(1)' : 'None' }" />
<span class="nav-text">{{ $t('header.voiceCloneManagement') }}</span>
</div>
<!-- 超级管理员显示音色克隆下拉菜单 -->
<el-dropdown v-if="isSuperAdmin" trigger="click" class="equipment-management more-dropdown"
:class="{ 'active-tab': $route.path === '/voice-clone-management' || $route.path === '/voice-resource-management' }"
@visible-change="handleVoiceCloneDropdownVisibleChange">
<span class="el-dropdown-link">
<img loading="lazy" alt="" src="@/assets/header/voice.png"
:style="{ filter: $route.path === '/voice-clone-management' || $route.path === '/voice-resource-management' ? 'brightness(0) invert(1)' : 'None' }" />
<span class="nav-text">{{ $t('header.voiceCloneManagement') }}</span>
<i class="el-icon-arrow-down el-icon--right" :class="{ 'rotate-down': voiceCloneDropdownVisible }"></i>
</span>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item @click.native="goVoiceCloneManagement">
{{ $t('header.voiceCloneManagement') }}
</el-dropdown-item>
<el-dropdown-item @click.native="goVoiceResourceManagement">
{{ $t('header.voiceResourceManagement') }}
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
<div v-if="isSuperAdmin" class="equipment-management" :class="{ 'active-tab': $route.path === '/model-config' }"
@click="goModelConfig">
<img loading="lazy" alt="" src="@/assets/header/model_config.png"
@@ -28,22 +56,19 @@
:style="{ filter: $route.path === '/user-management' ? 'brightness(0) invert(1)' : 'None' }" />
<span class="nav-text">{{ $t('header.userManagement') }}</span>
</div>
<div v-if="isSuperAdmin" class="equipment-management"
:class="{ 'active-tab': $route.path === '/ota-management' }" @click="goOtaManagement">
<img loading="lazy" alt="" src="@/assets/header/firmware_update.png"
:style="{ filter: $route.path === '/ota-management' ? 'brightness(0) invert(1)' : 'None' }" />
<span class="nav-text">{{ $t('header.otaManagement') }}</span>
</div>
<el-dropdown v-if="isSuperAdmin" trigger="click" class="equipment-management more-dropdown"
:class="{ 'active-tab': $route.path === '/dict-management' || $route.path === '/params-management' || $route.path === '/provider-management' || $route.path === '/server-side-management' || $route.path === '/agent-template-management' }"
:class="{ 'active-tab': $route.path === '/dict-management' || $route.path === '/params-management' || $route.path === '/provider-management' || $route.path === '/server-side-management' || $route.path === '/agent-template-management' || $route.path === '/ota-management' }"
@visible-change="handleParamDropdownVisibleChange">
<span class="el-dropdown-link">
<img loading="lazy" alt="" src="@/assets/header/param_management.png"
:style="{ filter: $route.path === '/dict-management' || $route.path === '/params-management' || $route.path === '/provider-management' || $route.path === '/server-side-management' || $route.path === '/agent-template-management' ? 'brightness(0) invert(1)' : 'None' }" />
:style="{ filter: $route.path === '/dict-management' || $route.path === '/params-management' || $route.path === '/provider-management' || $route.path === '/server-side-management' || $route.path === '/agent-template-management' || $route.path === '/ota-management' ? 'brightness(0) invert(1)' : 'None' }" />
<span class="nav-text">{{ $t('header.paramDictionary') }}</span>
<i class="el-icon-arrow-down el-icon--right" :class="{ 'rotate-down': paramDropdownVisible }"></i>
</span>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item @click.native="goOtaManagement">
{{ $t('header.otaManagement') }}
</el-dropdown-item>
<el-dropdown-item @click.native="goParamManagement">
{{ $t('header.paramManagement') }}
</el-dropdown-item>
@@ -91,37 +116,18 @@
</div>
</div>
<!-- 语言切换下拉菜单 -->
<el-dropdown trigger="click" class="language-dropdown" @visible-change="handleLanguageDropdownVisibleChange">
<span class="el-dropdown-link">
<span class="current-language-text">{{ currentLanguageText }}</span>
<i class="el-icon-arrow-down el-icon--right" :class="{ 'rotate-down': languageDropdownVisible }"></i>
</span>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item @click.native="changeLanguage('zh_CN')">
{{ $t('language.zhCN') }}
</el-dropdown-item>
<el-dropdown-item @click.native="changeLanguage('zh_TW')">
{{ $t('language.zhTW') }}
</el-dropdown-item>
<el-dropdown-item @click.native="changeLanguage('en')">
{{ $t('language.en') }}
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
<img loading="lazy" alt="" src="@/assets/home/avatar.png" class="avatar-img" />
<el-dropdown trigger="click" class="user-dropdown" @visible-change="handleUserDropdownVisibleChange">
<span class="el-dropdown-link">
{{ userInfo.username || '加载中...' }}
<i class="el-icon-arrow-down el-icon--right" :class="{ 'rotate-down': userDropdownVisible }"></i>
</span>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item @click.native="showChangePasswordDialog">{{ $t('header.changePassword')
}}</el-dropdown-item>
<el-dropdown-item @click.native="handleLogout">{{ $t('header.logout') }}</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
<img loading="lazy" alt="" src="@/assets/home/avatar.png" class="avatar-img" @click="handleAvatarClick" />
<span class="el-dropdown-link" @click="handleAvatarClick">
{{ userInfo.username || '加载中...' }}
<i class="el-icon-arrow-down el-icon--right"></i>
</span>
<el-cascader :options="userMenuOptions" trigger="click" :props="cascaderProps"
style="width: 0px;overflow: hidden;" :show-all-levels="false" @change="handleCascaderChange"
ref="userCascader">
<template slot-scope="{ data }">
<span>{{ data.label }}</span>
</template>
</el-cascader>
</div>
</div>
@@ -150,15 +156,21 @@ export default {
mobile: ''
},
isChangePasswordDialogVisible: false, // 控制修改密码弹窗的显示
userDropdownVisible: false,
paramDropdownVisible: false,
languageDropdownVisible: false,
voiceCloneDropdownVisible: false,
isSmallScreen: false,
// 搜索历史相关
searchHistory: [],
showHistory: false,
SEARCH_HISTORY_KEY: 'xiaozhi_search_history',
MAX_HISTORY_COUNT: 3
MAX_HISTORY_COUNT: 3,
// Cascader 配置
cascaderProps: {
expandTrigger: 'click',
value: 'value',
label: 'label',
children: 'children'
}
}
},
computed: {
@@ -183,6 +195,37 @@ export default {
default:
return this.$t('language.zhCN');
}
},
// 用户菜单选项
userMenuOptions() {
return [
{
label: this.currentLanguageText,
value: 'language',
children: [
{
label: this.$t('language.zhCN'),
value: 'zh_CN'
},
{
label: this.$t('language.zhTW'),
value: 'zh_TW'
},
{
label: this.$t('language.en'),
value: 'en'
}
]
},
{
label: this.$t('header.changePassword'),
value: 'changePassword'
},
{
label: this.$t('header.logout'),
value: 'logout'
}
];
}
},
mounted() {
@@ -207,6 +250,9 @@ export default {
goModelConfig() {
this.$router.push('/model-config')
},
goVoiceCloneManagement() {
this.$router.push('/voice-clone-management')
},
goParamManagement() {
this.$router.push('/params-management')
},
@@ -222,6 +268,11 @@ export default {
goServerSideManagement() {
this.$router.push('/server-side-management')
},
// 跳转到音色资源管理
goVoiceResourceManagement() {
this.$router.push('/voice-resource-management')
},
// 添加默认角色模板管理导航方法
goAgentTemplateManagement() {
this.$router.push('/agent-template-management')
@@ -364,27 +415,55 @@ export default {
});
}
},
handleUserDropdownVisibleChange(visible) {
this.userDropdownVisible = visible;
},
// 监听第二个下拉菜单的可见状态变化
// 监听参数字典下拉菜单的可见状态变化
handleParamDropdownVisibleChange(visible) {
this.paramDropdownVisible = visible;
},
// 监听语言下拉菜单的可见状态变化
handleLanguageDropdownVisibleChange(visible) {
this.languageDropdownVisible = visible;
// 监听音色克隆下拉菜单的可见状态变化
handleVoiceCloneDropdownVisibleChange(visible) {
this.voiceCloneDropdownVisible = visible;
},
// 处理 Cascader 选择变化
handleCascaderChange(value) {
if (!value || value.length === 0) {
return;
}
const action = value[value.length - 1];
// 处理语言切换
if (value.length === 2 && value[0] === 'language') {
this.changeLanguage(action);
return;
}
// 处理其他操作
switch (action) {
case 'changePassword':
this.showChangePasswordDialog();
break;
case 'logout':
this.handleLogout();
break;
}
},
// 切换语言
changeLanguage(lang) {
changeLanguage(lang);
this.languageDropdownVisible = false;
this.$message.success({
message: this.$t('message.success'),
showClose: true
});
},
// 点击头像触发cascader下拉菜单
handleAvatarClick() {
if (this.$refs.userCascader) {
this.$refs.userCascader.toggleDropDownVisible();
}
},
// 使用 mapActions 引入 Vuex 的 logout action
...mapActions(['logout'])
}
@@ -430,7 +509,7 @@ export default {
align-items: center;
gap: 25px;
position: absolute;
left: 44%;
left: 50%;
transform: translateX(-50%);
}
@@ -542,6 +621,13 @@ export default {
visibility: hidden;
}
.more-dropdown .el-dropdown-link {
display: flex;
align-items: center;
gap: 7px;
}
.search-history-item:hover .clear-item-icon {
visibility: visible;
}
@@ -579,41 +665,7 @@ export default {
width: 21px;
height: 21px;
flex-shrink: 0;
}
.user-dropdown {
flex-shrink: 0;
}
.language-dropdown {
flex-shrink: 0;
margin-right: 5px;
}
.current-language-text {
margin-left: 4px;
margin-right: 4px;
font-size: 12px;
color: #3d4566;
}
.more-dropdown {
padding-right: 20px;
}
.more-dropdown .el-dropdown-link {
display: flex;
align-items: center;
gap: 7px;
}
.rotate-down {
transform: rotate(180deg);
transition: transform 0.3s ease;
}
.el-icon-arrow-down {
transition: transform 0.3s ease;
cursor: pointer;
}
/* 导航文本样式 - 支持中英文换行 */
@@ -0,0 +1,694 @@
<template>
<el-dialog :title="$t('voiceClone.dialogTitle')" :visible.sync="visible" width="900px" top="10vh"
:before-close="handleClose" class="voice-clone-dialog">
<div class="dialog-content">
<!-- 步骤指示器 -->
<div class="steps-header">
<div class="step-item" :class="{ 'active': currentStep === 1, 'completed': currentStep > 1 }">
<div class="step-number">
<i class="el-icon-check" v-if="currentStep > 1"></i>
<span v-else>1</span>
</div>
<div class="step-label">{{ $t('voiceClone.stepUpload') }}</div>
<div class="step-line"></div>
</div>
<div class="step-item" :class="{ 'active': currentStep === 2, 'completed': currentStep > 2 }">
<div class="step-number">
<i class="el-icon-check" v-if="currentStep > 2"></i>
<span v-else>2</span>
</div>
<div class="step-label">{{ $t('voiceClone.stepEdit') }}</div>
</div>
</div>
<!-- 步骤1: 音频上传 -->
<div v-if="currentStep === 1" class="step-content">
<div class="upload-area">
<el-upload class="audio-uploader" drag :action="uploadAction" :auto-upload="false"
:on-change="handleFileChange" :show-file-list="false" accept="audio/*">
<i class="el-icon-upload"></i>
<div class="el-upload__text">{{ $t('voiceClone.dragOrClick') }}</div>
<div class="el-upload__tip">{{ $t('voiceClone.uploadTip') }}</div>
</el-upload>
</div>
</div>
<!-- 步骤2: 音频编辑 -->
<div v-if="currentStep === 2" class="step-content">
<div class="audio-edit-area">
<div class="edit-tips">
<p>{{ $t('voiceClone.editTip1') }}</p>
<p>{{ $t('voiceClone.editTip2') }}</p>
</div>
<!-- 波形显示区域 -->
<div class="waveform-container">
<canvas ref="waveformCanvas" class="waveform-canvas" @mousedown="handleWaveformMouseDown"
@mousemove="handleWaveformMouseMove" @mouseup="handleWaveformMouseUp"></canvas>
<div class="selection-overlay" v-if="isSelecting || selectionStart !== null"
:style="selectionStyle"></div>
<div class="duration-display">
{{ $t('voiceClone.selectedDuration', { duration: selectedDuration.toFixed(1) }) }}
</div>
</div>
<!-- 音频控制按钮 -->
<div class="audio-controls">
<el-button size="small" :icon="isPlaying ? 'el-icon-video-pause' : 'el-icon-video-play'"
@click="togglePlay" type="primary">
{{ isPlaying ? $t('voiceClone.pause') : $t('voiceClone.play') }}
</el-button>
<el-button size="small" icon="el-icon-scissors" @click="handleTrim"
:disabled="selectionStart === null">
{{ $t('voiceClone.trim') }}
</el-button>
<el-button size="small" icon="el-icon-refresh-left" @click="handleReset">
{{ $t('voiceClone.reset') }}
</el-button>
</div>
<!-- 音频元素 -->
<audio ref="audioPlayer" @timeupdate="handleTimeUpdate" @ended="handleAudioEnded"
style="display: none;"></audio>
</div>
</div>
</div>
<span slot="footer" class="dialog-footer">
<el-button @click="handleClose">{{ currentStep === 1 ? $t('voiceClone.cancel') : $t('voiceClone.prevStep')
}}</el-button>
<el-button type="primary" @click="handleNext" :loading="uploading">
{{ currentStep === 1 ? $t('voiceClone.nextStep') : $t('voiceClone.upload') }}
</el-button>
</span>
</el-dialog>
</template>
<script>
import Api from "@/apis/api";
export default {
name: 'VoiceCloneDialog',
props: {
visible: {
type: Boolean,
default: false
},
voiceCloneData: {
type: Object,
default: () => ({})
}
},
data() {
return {
currentStep: 1,
uploadAction: '',
audioFile: null,
originalAudioFile: null,
audioBuffer: null,
originalAudioBuffer: null,
isPlaying: false,
uploading: false,
// 波形相关
waveformData: [],
// 选择相关
isSelecting: false,
selectionStart: null,
selectionEnd: null,
mouseStartX: 0,
// 音频上下文
audioContext: null,
audioSource: null,
};
},
computed: {
selectedDuration() {
if (this.selectionStart !== null && this.selectionEnd !== null && this.audioBuffer) {
const duration = this.audioBuffer.duration;
const start = Math.min(this.selectionStart, this.selectionEnd) * duration;
const end = Math.max(this.selectionStart, this.selectionEnd) * duration;
return end - start;
}
return this.audioBuffer ? this.audioBuffer.duration : 0;
},
selectionStyle() {
if (this.selectionStart === null) return {};
const canvas = this.$refs.waveformCanvas;
if (!canvas) return {};
const start = Math.min(this.selectionStart, this.selectionEnd || this.selectionStart);
const end = Math.max(this.selectionStart, this.selectionEnd || this.selectionStart);
return {
left: `${start * 100}%`,
width: `${(end - start) * 100}%`
};
}
},
methods: {
handleClose() {
if (this.currentStep === 2) {
this.currentStep = 1;
} else {
this.resetDialog();
this.$emit('update:visible', false);
}
},
resetDialog() {
this.currentStep = 1;
this.audioFile = null;
this.originalAudioFile = null;
this.audioBuffer = null;
this.originalAudioBuffer = null;
this.isPlaying = false;
this.uploading = false;
this.waveformData = [];
this.selectionStart = null;
this.selectionEnd = null;
if (this.audioSource) {
this.audioSource.stop();
this.audioSource = null;
}
},
async handleFileChange(file) {
if (!file || !file.raw) return;
this.audioFile = file.raw;
this.originalAudioFile = file.raw;
// 先进入第二步,确保DOM已渲染
this.currentStep = 2;
// 等待DOM更新后再加载音频
await this.$nextTick();
await this.loadAudio(file.raw);
},
async loadAudio(file) {
try {
const arrayBuffer = await file.arrayBuffer();
if (!this.audioContext) {
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
this.audioBuffer = await this.audioContext.decodeAudioData(arrayBuffer.slice(0));
this.originalAudioBuffer = await this.audioContext.decodeAudioData(await file.arrayBuffer());
// 设置音频播放器
if (this.$refs.audioPlayer) {
const audioUrl = URL.createObjectURL(file);
this.$refs.audioPlayer.src = audioUrl;
// 加载音频元数据
this.$refs.audioPlayer.load();
}
// 生成波形数据
await this.generateWaveform();
} catch (error) {
console.error('加载音频失败:', error);
this.$message.error(this.$t('voiceClone.loadAudioFailed'));
}
},
async generateWaveform() {
if (!this.audioBuffer) return;
await this.$nextTick();
const canvas = this.$refs.waveformCanvas;
if (!canvas) {
console.error('Canvas元素不存在');
return;
}
// 设置canvas大小
const containerWidth = canvas.parentElement.offsetWidth;
const containerHeight = canvas.parentElement.offsetHeight;
canvas.width = containerWidth || 800;
canvas.height = containerHeight || 200;
const canvasWidth = canvas.width;
const channelData = this.audioBuffer.getChannelData(0);
const step = Math.floor(channelData.length / canvasWidth);
const waveformData = [];
for (let i = 0; i < canvasWidth; i++) {
let sum = 0;
for (let j = 0; j < step; j++) {
sum += Math.abs(channelData[i * step + j] || 0);
}
waveformData.push(sum / step);
}
this.waveformData = waveformData;
this.drawWaveform();
},
drawWaveform() {
const canvas = this.$refs.waveformCanvas;
if (!canvas) {
console.error('绘制波形时Canvas不存在');
return;
}
const ctx = canvas.getContext('2d');
const width = canvas.width;
const height = canvas.height;
// 清空画布
ctx.clearRect(0, 0, width, height);
// 绘制背景
ctx.fillStyle = '#e0f2ff';
ctx.fillRect(0, 0, width, height);
if (this.waveformData.length === 0) {
console.error('波形数据为空');
return;
}
// 找到最大值用于归一化
const maxValue = Math.max(...this.waveformData);
// 绘制波形
ctx.fillStyle = '#4ade80';
ctx.strokeStyle = '#4ade80';
ctx.lineWidth = 1;
const barWidth = width / this.waveformData.length;
this.waveformData.forEach((value, index) => {
// 归一化并放大,使用80%的高度
const normalizedValue = maxValue > 0 ? value / maxValue : 0;
const barHeight = Math.max(1, normalizedValue * height * 0.8);
const x = index * barWidth;
const y = (height - barHeight) / 2;
ctx.fillRect(x, y, Math.max(1, barWidth - 1), barHeight);
});
},
handleWaveformMouseDown(e) {
const canvas = this.$refs.waveformCanvas;
const rect = canvas.getBoundingClientRect();
this.mouseStartX = e.clientX - rect.left;
this.selectionStart = this.mouseStartX / rect.width;
this.selectionEnd = this.selectionStart;
this.isSelecting = true;
},
handleWaveformMouseMove(e) {
if (!this.isSelecting) return;
const canvas = this.$refs.waveformCanvas;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
this.selectionEnd = Math.max(0, Math.min(1, x / rect.width));
},
handleWaveformMouseUp() {
this.isSelecting = false;
},
async handleTrim() {
if (this.selectionStart === null || this.selectionEnd === null || !this.audioBuffer) return;
const start = Math.min(this.selectionStart, this.selectionEnd);
const end = Math.max(this.selectionStart, this.selectionEnd);
// 创建新的音频buffer
const duration = this.audioBuffer.duration;
const startTime = start * duration;
const endTime = end * duration;
const startOffset = Math.floor(startTime * this.audioBuffer.sampleRate);
const endOffset = Math.floor(endTime * this.audioBuffer.sampleRate);
const newLength = endOffset - startOffset;
const newBuffer = this.audioContext.createBuffer(
this.audioBuffer.numberOfChannels,
newLength,
this.audioBuffer.sampleRate
);
for (let channel = 0; channel < this.audioBuffer.numberOfChannels; channel++) {
const oldData = this.audioBuffer.getChannelData(channel);
const newData = newBuffer.getChannelData(channel);
for (let i = 0; i < newLength; i++) {
newData[i] = oldData[startOffset + i];
}
}
this.audioBuffer = newBuffer;
// 更新音频文件
await this.bufferToFile(newBuffer);
// 重置选择
this.selectionStart = null;
this.selectionEnd = null;
// 重新生成波形
this.generateWaveform();
this.$message.success(this.$t('voiceClone.trimSuccess'));
},
async handleReset() {
if (!this.originalAudioFile) return;
this.audioFile = this.originalAudioFile;
await this.loadAudio(this.originalAudioFile);
this.selectionStart = null;
this.selectionEnd = null;
this.$message.success(this.$t('voiceClone.resetSuccess'));
},
togglePlay() {
const audio = this.$refs.audioPlayer;
if (this.isPlaying) {
audio.pause();
this.isPlaying = false;
} else {
audio.play();
this.isPlaying = true;
}
},
handleTimeUpdate() {
// 可以在这里更新播放进度
},
handleAudioEnded() {
this.isPlaying = false;
},
async bufferToFile(buffer) {
// 将AudioBuffer转换为WAV文件
const wav = this.audioBufferToWav(buffer);
const blob = new Blob([wav], { type: 'audio/wav' });
this.audioFile = new File([blob], 'audio.wav', { type: 'audio/wav' });
// 更新播放器
await this.$nextTick();
if (this.$refs.audioPlayer) {
const audioUrl = URL.createObjectURL(blob);
this.$refs.audioPlayer.src = audioUrl;
}
},
audioBufferToWav(buffer) {
const length = buffer.length * buffer.numberOfChannels * 2 + 44;
const arrayBuffer = new ArrayBuffer(length);
const view = new DataView(arrayBuffer);
const channels = [];
let offset = 0;
let pos = 0;
// 写入WAV文件头
const setUint16 = (data) => {
view.setUint16(pos, data, true);
pos += 2;
};
const setUint32 = (data) => {
view.setUint32(pos, data, true);
pos += 4;
};
// "RIFF" chunk descriptor
setUint32(0x46464952); // "RIFF"
setUint32(length - 8); // file length - 8
setUint32(0x45564157); // "WAVE"
// "fmt " sub-chunk
setUint32(0x20746d66); // "fmt "
setUint32(16); // SubChunk1Size = 16
setUint16(1); // AudioFormat = 1 (PCM)
setUint16(buffer.numberOfChannels);
setUint32(buffer.sampleRate);
setUint32(buffer.sampleRate * 2 * buffer.numberOfChannels); // byte rate
setUint16(buffer.numberOfChannels * 2); // block align
setUint16(16); // bits per sample
// "data" sub-chunk
setUint32(0x61746164); // "data"
setUint32(length - pos - 4); // SubChunk2Size
// 写入音频数据
for (let i = 0; i < buffer.numberOfChannels; i++) {
channels.push(buffer.getChannelData(i));
}
while (pos < length) {
for (let i = 0; i < buffer.numberOfChannels; i++) {
let sample = Math.max(-1, Math.min(1, channels[i][offset]));
sample = sample < 0 ? sample * 0x8000 : sample * 0x7FFF;
view.setInt16(pos, sample, true);
pos += 2;
}
offset++;
}
return arrayBuffer;
},
async handleNext() {
if (this.currentStep === 1) {
// 验证是否已选择文件
if (!this.audioFile) {
this.$message.warning(this.$t('voiceClone.pleaseSelectAudio'));
return;
}
this.currentStep = 2;
} else {
// 上传音频
await this.uploadAudio();
}
},
async uploadAudio() {
if (!this.audioFile) {
this.$message.warning(this.$t('voiceClone.pleaseSelectAudio'));
return;
}
// 验证音频时长(8-60秒)
if (this.audioBuffer) {
const duration = this.audioBuffer.duration;
if (duration < 8 || duration > 60) {
this.$message.warning(this.$t('voiceClone.durationError'));
return;
}
}
this.uploading = true;
try {
const formData = new FormData();
formData.append('voiceFile', this.audioFile);
formData.append('id', this.voiceCloneData.id);
await Api.voiceClone.uploadVoice(formData, (res) => {
this.uploading = false;
res = res.data;
if (res.code === 0) {
this.$message.success(this.$t('voiceClone.uploadSuccess'));
this.resetDialog();
this.$emit('update:visible', false);
this.$emit('success');
} else {
this.$message.error(res.msg || this.$t('voiceClone.uploadFailed'));
}
});
} catch (error) {
this.uploading = false;
console.error('上传音频失败:', error);
this.$message.error(this.$t('voiceClone.uploadFailed'));
}
}
},
mounted() {
// 设置canvas大小
this.$nextTick(() => {
const canvas = this.$refs.waveformCanvas;
if (canvas) {
canvas.width = canvas.offsetWidth;
canvas.height = canvas.offsetHeight;
}
});
},
beforeDestroy() {
if (this.audioContext) {
this.audioContext.close();
}
}
};
</script>
<style lang="scss" scoped>
.voice-clone-dialog {
::v-deep .el-dialog__body {
padding: 20px 30px;
}
}
.steps-header {
display: flex;
justify-content: center;
align-items: center;
padding: 0 50px;
}
.step-item {
display: flex;
align-items: center;
position: relative;
&.active {
.step-number {
background: #6b8cff;
color: white;
}
.step-label {
color: #333;
font-weight: 600;
}
}
&.completed {
.step-number {
background: #67c23a;
color: white;
}
.step-line {
background: #67c23a;
}
}
}
.step-number {
width: 36px;
height: 36px;
border-radius: 50%;
background: #e4e7ed;
color: #909399;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 16px;
z-index: 1;
}
.step-label {
margin-left: 12px;
color: #909399;
font-size: 14px;
}
.step-line {
width: 200px;
height: 2px;
background: #e4e7ed;
margin-left: 12px;
}
.step-content {
padding: 20px 0;
}
.upload-area {
padding: 40px 0;
}
.audio-uploader {
::v-deep .el-upload {
width: 100%;
}
::v-deep .el-upload-dragger {
width: 100%;
height: 280px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
.el-icon-upload {
font-size: 67px;
color: #c0c4cc;
margin: 0 0 16px;
line-height: 50px;
}
}
.el-upload__text {
color: #606266;
font-size: 14px;
margin-bottom: 8px;
}
.el-upload__tip {
font-size: 12px;
color: #909399;
margin-top: 7px;
}
}
.audio-edit-area {
padding: 0 20px;
}
.edit-tips {
margin-bottom: 20px;
padding: 12px;
background: #f0f9ff;
border-radius: 4px;
border-left: 3px solid #1890ff;
p {
margin: 4px 0;
font-size: 13px;
color: #606266;
&:first-child {
font-weight: 500;
}
}
}
.waveform-container {
position: relative;
width: 100%;
height: 200px;
margin-bottom: 20px;
border: 1px solid #e4e7ed;
border-radius: 4px;
overflow: hidden;
cursor: crosshair;
}
.waveform-canvas {
width: 100%;
height: 100%;
}
.selection-overlay {
position: absolute;
top: 0;
bottom: 0;
background: rgba(107, 140, 255, 0.3);
border: 1px solid #6b8cff;
pointer-events: none;
}
.duration-display {
position: absolute;
top: 8px;
right: 8px;
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
}
.audio-controls {
display: flex;
justify-content: center;
gap: 12px;
margin-top: 20px;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
</style>
@@ -0,0 +1,154 @@
<template>
<el-dialog :title="title" :visible.sync="dialogVisible" :close-on-click-modal="false" @close="handleClose"
@open="handleOpen" width="500px">
<el-form ref="form" :model="form" :rules="rules" label-width="120px">
<el-form-item :label="$t('voiceClone.platformName')" prop="modelId">
<el-select v-model="form.modelId" :placeholder="$t('voiceClone.platformNamePlaceholder')" filterable
style="width: 100%" @change="handlePlatformChange">
<el-option v-for="model in platformList" :key="model.id" :label="model.modelName" :value="model.id">
</el-option>
</el-select>
</el-form-item>
<el-form-item :label="$t('voiceClone.voiceId')" prop="voiceIds">
<el-select v-model="form.voiceIds" :placeholder="$t('voiceClone.voiceIdPlaceholder')" filterable multiple
allow-create default-first-option style="width: 100%">
<el-option v-for="voiceId in voiceIdList" :key="voiceId" :label="voiceId" :value="voiceId">
</el-option>
</el-select>
</el-form-item>
<el-form-item :label="$t('voiceClone.userId')" prop="userId">
<el-select v-model="form.userId" :placeholder="$t('voiceClone.userIdPlaceholder')" filterable remote
:remote-method="remoteSearchUser" :loading="userLoading" style="width: 100%">
<el-option v-for="user in userList" :key="user.userid" :label="user.mobile" :value="user.userid">
</el-option>
</el-select>
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button @click="handleCancel">{{ $t('voiceClone.operationCancelled') }}</el-button>
<el-button type="primary" @click="handleSubmit">{{ $t('voiceClone.addNew') }}</el-button>
</div>
</el-dialog>
</template>
<script>
import Api from '@/apis/api';
export default {
name: 'VoiceCloneDialog',
props: {
visible: {
type: Boolean,
default: false
},
title: {
type: String,
default: ''
},
form: {
type: Object,
default: () => ({})
}
},
data() {
return {
dialogVisible: this.visible,
platformList: [],
voiceIdList: [],
userList: [],
userLoading: false,
rules: {
modelId: [
{ required: true, message: this.$t('voiceClone.platformNameRequired'), trigger: 'change' }
],
voiceIds: [
{ required: true, message: this.$t('voiceClone.voiceIdRequired'), trigger: 'change' }
],
userId: [
{ required: true, message: this.$t('voiceClone.userIdRequired'), trigger: 'change' }
]
}
}
},
watch: {
visible(val) {
this.dialogVisible = val;
},
dialogVisible(val) {
this.$emit('update:visible', val);
}
},
methods: {
handleClose() {
this.dialogVisible = false;
this.$emit('cancel');
},
handleCancel() {
this.$refs.form.clearValidate();
this.$emit('cancel');
},
handleSubmit() {
this.$refs.form.validate(valid => {
if (valid) {
this.$emit('submit', this.form);
}
});
},
handleOpen() {
// 对话框打开时加载平台列表
this.fetchPlatformList();
// 重置音色ID列表
this.voiceIdList = [];
},
handlePlatformChange(modelId) {
// 清空音色ID选择
this.form.voiceIds = [];
},
// 获取TTS平台列表
fetchPlatformList() {
Api.voiceResource.getTtsPlatformList((res) => {
if (res.data.code === 0) {
this.platformList = res.data.data;
}
});
},
// 远程搜索用户
remoteSearchUser(query) {
if (query !== '') {
this.userLoading = true;
const params = {
page: 1,
limit: 20,
mobile: query
};
Api.admin.getUserList(params, (res) => {
this.userLoading = false;
if (res.data.code === 0) {
this.userList = res.data.data.list;
}
});
} else {
this.userList = [];
}
}
}
}
</script>
<style lang="scss" scoped>
::v-deep .el-dialog {
border-radius: 20px;
}
</style>