Files
sionrui/frontend/app/web-gold/src/views/dh/VoiceCopy.vue
2025-11-10 00:59:40 +08:00

314 lines
10 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.
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { message, Modal } from 'ant-design-vue'
import { useVoiceCopyStore } from '@/stores/voiceCopy'
import useVoiceText from '@gold/hooks/web/useVoiceText'
const store = useVoiceCopyStore()
const profiles = computed(() => store.profiles)
const activeId = computed(() => store.activeId)
const { getVoiceText } = useVoiceText()
const form = reactive({
id: '',
name: '',
language: 'zh-CN', // 简体中文
gender: 'female',
referenceAudio: '', // dataURL 或外链
sampleText: '今天天气很好,我们一起去公园散步吧。',
enhancement: 50, // 增强强度 0-100去噪/清晰度)
noiseReduction: true,
note: '',
originalText: '', // 原语音文本
})
const isSavingAs = ref(false)
const saveAsName = ref('')
// function generateId() {
// return `${Date.now()}_${Math.floor(Math.random()*1e6)}`
// }
async function loadProfiles() { await store.load(); if (store.activeProfile) Object.assign(form, { ...store.activeProfile }) }
function toDataURL(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result)
reader.onerror = reject
reader.readAsDataURL(file)
})
}
async function onUploadBefore(file) {
try {
const dataUrl = await toDataURL(file)
form.referenceAudio = dataUrl
message.success('音频已就绪(本地预处理)')
} catch {
message.error('读取音频失败,请重试')
}
return false // 阻止 antd 自动上传
}
function resetForm() {
Object.assign(form, {
id: '',
name: '',
language: 'zh-CN',
gender: 'female',
referenceAudio: '',
sampleText: '今天天气很好,我们一起去公园散步吧。',
enhancement: 50,
noiseReduction: true,
note: '',
originalText: '',
})
}
function validate() {
if (!form.referenceAudio) {
message.warning('请先上传或粘贴一段参考音频')
return false
}
return true
}
async function transcribeOriginal() {
const url = (form.referenceAudio || '').trim()
if (!url) { message.warning('请先提供参考音频'); return }
if (!/^https?:\/\//i.test(url)) {
message.info('当前仅支持网络音频链接一键转写,请粘贴 http(s) 链接')
return
}
try {
const list = await getVoiceText([{ audio_url: url }])
const text = Array.isArray(list) && list[0]?.value ? list[0].value : ''
if (text) {
form.originalText = text
message.success('已获取原语音文本')
} else {
message.warning('未获取到可用文本,请稍后重试')
}
} catch (e) {
console.error(e)
message.error('转写失败,请稍后重试')
}
}
async function saveProfile() {
if (!validate()) return
if (!form.id) {
const created = await store.add({ ...form, id: '' })
Object.assign(form, { ...created })
message.success('已保存到本地')
} else {
const updated = await store.update({ ...form })
Object.assign(form, { ...updated })
message.success('已更新')
}
}
function openSaveAs() {
if (!validate()) return
saveAsName.value = form.name ? `${form.name}-副本` : ''
isSavingAs.value = true
}
async function confirmSaveAs() {
const name = (saveAsName.value || '').trim()
if (!name) {
message.warning('请输入名称')
return
}
const created = await store.duplicate({ ...form }, name)
Object.assign(form, { ...created })
isSavingAs.value = false
message.success('已另存为')
}
function selectProfile(p) {
if (!p) return
store.select(p.id)
Object.assign(form, { ...p })
}
function requestDelete(p) {
Modal.confirm({
title: '删除克隆声音',
content: `确定删除「${p.name || '未命名'}」吗?此操作不可恢复。`,
okText: '删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
await store.remove(p.id)
if (!store.activeProfile) { resetForm() } else { Object.assign(form, { ...store.activeProfile }) }
message.success('已删除')
}
})
}
onMounted(loadProfiles)
</script>
<template>
<div class="vc-page">
<div class="vc-grid">
<!-- 左侧Cosy Voice 表单 -->
<section class="vc-left">
<div class="vc-title">语音克隆</div>
<div class="vc-steps">
<span class="step">1. 上传音频</span>
<span class="step">2. 填文本</span>
<span class="step">3. 保存</span>
</div>
<a-form layout="vertical">
<a-form-item label="名称(可选)">
<a-input v-model:value="form.name" placeholder="给你的克隆声音起个名字" allow-clear />
</a-form-item>
<a-form-item label="语言">
<a-select v-model:value="form.language" :options="[
{ value: 'zh-CN', label: '简体中文' },
{ value: 'zh-TW', label: '繁體中文' },
{ value: 'en-US', label: 'English' }
]" />
</a-form-item>
<a-form-item label="性别/音色">
<a-radio-group v-model:value="form.gender">
<a-radio value="female">女声更柔和</a-radio>
<a-radio value="male">男声更低沉</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="参考音频">
<a-upload :before-upload="onUploadBefore" :show-upload-list="false" accept="audio/*">
<a-button type="default">选择音频文件</a-button>
</a-upload>
<a-input v-model:value="form.referenceAudio" placeholder="或粘贴音频外链" style="margin-top:8px" />
<div v-if="form.referenceAudio" class="vc-audio-preview">
<audio :src="form.referenceAudio" controls preload="metadata" />
</div>
</a-form-item>
<a-form-item label="示例文本(可读一段样例,提升效果)">
<a-textarea v-model:value="form.sampleText" :rows="3" placeholder="示例:今天天气很好,我们一起去公园散步吧。" />
</a-form-item>
<a-form-item label="原语音文本">
<a-textarea v-model:value="form.originalText" :rows="4" placeholder="填写参考音频中的发音文本" />
<div class="vc-row" style="margin-top:8px">
<a-button @click="transcribeOriginal">一键转写</a-button>
<div class="hint" style="margin-left:12px">仅支持网络链接</div>
</div>
</a-form-item>
<div class="vc-row">
<a-form-item label="去噪优化" style="flex:1">
<a-switch v-model:checked="form.noiseReduction" />
</a-form-item>
<a-form-item label="增强强度" style="flex:2">
<a-slider v-model:value="form.enhancement" :min="0" :max="100" />
</a-form-item>
</div>
<a-form-item label="备注(可选)">
<a-textarea v-model:value="form.note" :rows="2" />
</a-form-item>
<div class="vc-actions">
<a-space>
<a-button type="primary" @click="saveProfile">保存</a-button>
<a-button @click="openSaveAs">另存为</a-button>
<a-button @click="resetForm">重置</a-button>
</a-space>
<div class="hint" style="margin-top:6px">保存后可在右侧管理</div>
</div>
</a-form>
</section>
<!-- 右侧已保存的克隆声音列表 -->
<section class="vc-right">
<div class="vc-title">已保存的克隆声音</div>
<div v-if="!profiles.length" class="vc-empty">暂无记录保存后会出现在这里</div>
<ul v-else class="vc-list">
<li v-for="p in profiles" :key="p.id" class="vc-item" :class="{ active: p.id === activeId }" @click="selectProfile(p)">
<div class="vc-item-main">
<div class="vc-item-name">{{ p.name || '未命名' }}</div>
<div class="vc-item-sub">{{ p.language }} · {{ p.gender === 'female' ? '女声' : '男声' }}</div>
</div>
<button class="vc-item-del" @click.stop="requestDelete(p)" aria-label="删除">×</button>
</li>
</ul>
</section>
</div>
<a-modal v-model:open="isSavingAs" title="另存为" :maskClosable="false" @ok="confirmSaveAs" @cancel="() => (isSavingAs = false)">
<a-input v-model:value="saveAsName" placeholder="输入名称,如:小红-普通话-温柔女声" />
</a-modal>
</div>
</template>
<style scoped>
.vc-page { color: var(--color-text); }
.vc-grid {
display: grid;
grid-template-columns: 1fr;
gap: 16px;
}
@media (min-width: 1024px) {
.vc-grid { grid-template-columns: 1fr 1fr; }
}
.vc-left, .vc-right {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-card);
box-shadow: var(--shadow-inset-card);
padding: 16px;
}
.vc-title { font-size: 14px; color: var(--color-text-secondary); margin-bottom: 8px; }
.vc-steps { display:flex; flex-wrap: wrap; gap:8px; margin-bottom: 8px; }
.vc-steps .step { font-size: 12px; color: var(--color-text-secondary); background: #161616; border: 1px solid var(--color-border); padding: 4px 8px; border-radius: 999px; }
.vc-row { display: flex; gap: 16px; }
.vc-actions { margin-top: 8px; }
.hint { font-size: 12px; color: var(--color-text-secondary); margin-top: 6px; }
.vc-audio-preview { margin-top: 8px; }
.vc-audio-preview audio { width: 100%; }
.vc-empty { color: var(--color-text-secondary); padding: 12px; }
.vc-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 8px; }
.vc-item {
display: flex; align-items: center; justify-content: space-between;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-card);
padding: 10px 12px; cursor: pointer;
transition: background .15s ease, border-color .15s ease, transform .1s ease;
}
.vc-item:hover { background: #161616; }
.vc-item.active { border-color: var(--color-primary); }
.vc-item-main { display: flex; flex-direction: column; }
.vc-item-name { font-size: 14px; color: var(--color-text); font-weight: 600; }
.vc-item-sub { font-size: 12px; color: var(--color-text-secondary); }
.vc-item-del {
visibility: hidden;
width: 24px; height: 24px; border-radius: 6px;
background: #2a2a2a; color: #fff; border: 1px solid var(--color-border);
}
.vc-item:hover .vc-item-del { visibility: visible; }
.vc-item-del:hover { background: #3a3a3a; }
</style>