2025-12-28 00:19:17 +08:00
|
|
|
|
<template>
|
|
|
|
|
|
<div class="voice-selector">
|
|
|
|
|
|
<div v-if="displayedVoices.length === 0" class="empty-voices">
|
|
|
|
|
|
还没有配音,可先在"配音管理"中上传
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div v-else class="voice-selector-with-preview">
|
|
|
|
|
|
<a-select
|
|
|
|
|
|
v-model:value="selectedVoiceId"
|
|
|
|
|
|
placeholder="请选择音色"
|
|
|
|
|
|
class="voice-select"
|
|
|
|
|
|
:options="voiceOptions"
|
|
|
|
|
|
@change="handleVoiceChange"
|
|
|
|
|
|
style="width: calc(100% - 80px)"
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<a-button
|
|
|
|
|
|
class="preview-button"
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
:disabled="!selectedVoiceId"
|
|
|
|
|
|
:loading="previewLoadingVoiceId === selectedVoiceId"
|
|
|
|
|
|
@click="handlePreviewCurrentVoice"
|
|
|
|
|
|
>
|
|
|
|
|
|
<template #icon>
|
|
|
|
|
|
<SoundOutlined />
|
|
|
|
|
|
</template>
|
|
|
|
|
|
试听
|
|
|
|
|
|
</a-button>
|
|
|
|
|
|
</div>
|
2026-02-02 22:36:20 +08:00
|
|
|
|
|
|
|
|
|
|
<!-- APlayer 播放器容器 -->
|
|
|
|
|
|
<div v-if="audioUrl" ref="playerContainer" class="aplayer-container"></div>
|
2025-12-28 00:19:17 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup>
|
2026-02-02 22:36:20 +08:00
|
|
|
|
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
2025-12-28 00:19:17 +08:00
|
|
|
|
import { useVoiceCopyStore } from '@/stores/voiceCopy'
|
|
|
|
|
|
import { useTTS, TTS_PROVIDERS } from '@/composables/useTTS'
|
2026-02-02 22:36:20 +08:00
|
|
|
|
import APlayer from 'aplayer'
|
2025-12-28 00:19:17 +08:00
|
|
|
|
|
|
|
|
|
|
const voiceStore = useVoiceCopyStore()
|
|
|
|
|
|
|
|
|
|
|
|
const emit = defineEmits(['select'])
|
|
|
|
|
|
|
2026-02-02 22:36:20 +08:00
|
|
|
|
// APlayer 实例
|
|
|
|
|
|
let player = null
|
|
|
|
|
|
const playerContainer = ref(null)
|
|
|
|
|
|
const audioUrl = ref('')
|
|
|
|
|
|
const currentVoiceName = ref('')
|
|
|
|
|
|
|
2026-02-02 22:42:15 +08:00
|
|
|
|
// 默认封面图片(音频波形图标)
|
|
|
|
|
|
const defaultCover = `data:image/svg+xml;base64,${btoa(`
|
|
|
|
|
|
<svg width="100" height="100" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
|
|
|
|
|
<rect width="100" height="100" fill="#1f2937" rx="8"/>
|
|
|
|
|
|
<g fill="#60a5fa">
|
|
|
|
|
|
<rect x="20" y="35" width="4" height="30" rx="2">
|
|
|
|
|
|
<animate attributeName="height" values="30;20;30" dur="0.8s" repeatCount="indefinite"/>
|
|
|
|
|
|
<animate attributeName="y" values="35;40;35" dur="0.8s" repeatCount="indefinite"/>
|
|
|
|
|
|
</rect>
|
|
|
|
|
|
<rect x="30" y="30" width="4" height="40" rx="2">
|
|
|
|
|
|
<animate attributeName="height" values="40;25;40" dur="0.6s" repeatCount="indefinite"/>
|
|
|
|
|
|
<animate attributeName="y" values="30;37.5;30" dur="0.6s" repeatCount="indefinite"/>
|
|
|
|
|
|
</rect>
|
|
|
|
|
|
<rect x="40" y="25" width="4" height="50" rx="2">
|
|
|
|
|
|
<animate attributeName="height" values="50;30;50" dur="0.7s" repeatCount="indefinite"/>
|
|
|
|
|
|
<animate attributeName="y" values="25;35;25" dur="0.7s" repeatCount="indefinite"/>
|
|
|
|
|
|
</rect>
|
|
|
|
|
|
<rect x="50" y="28" width="4" height="44" rx="2">
|
|
|
|
|
|
<animate attributeName="height" values="44;28;44" dur="0.9s" repeatCount="indefinite"/>
|
|
|
|
|
|
<animate attributeName="y" values="28;36;28" dur="0.9s" repeatCount="indefinite"/>
|
|
|
|
|
|
</rect>
|
|
|
|
|
|
<rect x="60" y="32" width="4" height="36" rx="2">
|
|
|
|
|
|
<animate attributeName="height" values="36;22;36" dur="0.5s" repeatCount="indefinite"/>
|
|
|
|
|
|
<animate attributeName="y" values="32;39;32" dur="0.5s" repeatCount="indefinite"/>
|
|
|
|
|
|
</rect>
|
|
|
|
|
|
<rect x="70" y="38" width="4" height="24" rx="2">
|
|
|
|
|
|
<animate attributeName="height" values="24;15;24" dur="0.7s" repeatCount="indefinite"/>
|
|
|
|
|
|
<animate attributeName="y" values="38;42.5;38" dur="0.7s" repeatCount="indefinite"/>
|
|
|
|
|
|
</rect>
|
|
|
|
|
|
</g>
|
|
|
|
|
|
<circle cx="50" cy="50" r="18" fill="none" stroke="#60a5fa" stroke-width="2" opacity="0.3"/>
|
|
|
|
|
|
<path d="M44 44 L44 56 L56 50 Z" fill="#60a5fa" opacity="0.5"/>
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
`.trim())}`
|
2026-02-02 22:36:20 +08:00
|
|
|
|
|
|
|
|
|
|
// 使用TTS Hook
|
2025-12-28 00:19:17 +08:00
|
|
|
|
const {
|
|
|
|
|
|
previewLoadingVoiceId,
|
|
|
|
|
|
playingPreviewVoiceId,
|
|
|
|
|
|
ttsText,
|
|
|
|
|
|
speechRate,
|
|
|
|
|
|
playVoiceSample,
|
|
|
|
|
|
setText,
|
|
|
|
|
|
setSpeechRate,
|
|
|
|
|
|
resetPreviewState
|
|
|
|
|
|
} = useTTS({
|
2026-02-01 21:11:29 +08:00
|
|
|
|
provider: TTS_PROVIDERS.SILICONFLOW
|
2025-12-28 00:19:17 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 当前选中的音色ID
|
|
|
|
|
|
const selectedVoiceId = ref('')
|
|
|
|
|
|
|
|
|
|
|
|
// 从store数据构建音色列表
|
|
|
|
|
|
const userVoiceCards = computed(() =>
|
|
|
|
|
|
(voiceStore.profiles || []).map(profile => ({
|
|
|
|
|
|
id: `user-${profile.id}`,
|
|
|
|
|
|
rawId: profile.id,
|
|
|
|
|
|
name: profile.name || '未命名',
|
|
|
|
|
|
category:'',
|
|
|
|
|
|
gender: profile.gender || 'female',
|
|
|
|
|
|
description: profile.note || '我的配音',
|
|
|
|
|
|
fileUrl: profile.fileUrl,
|
|
|
|
|
|
transcription: profile.transcription || '',
|
|
|
|
|
|
source: 'user',
|
|
|
|
|
|
voiceId: profile.voiceId
|
|
|
|
|
|
}))
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const displayedVoices = computed(() => userVoiceCards.value)
|
|
|
|
|
|
|
|
|
|
|
|
// 转换为下拉框选项格式
|
|
|
|
|
|
const voiceOptions = computed(() =>
|
|
|
|
|
|
displayedVoices.value.map(voice => ({
|
|
|
|
|
|
value: voice.id,
|
|
|
|
|
|
label: voice.name,
|
|
|
|
|
|
data: voice // 保存完整数据
|
|
|
|
|
|
}))
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// 音色选择变化处理
|
|
|
|
|
|
const handleVoiceChange = (value, option) => {
|
|
|
|
|
|
const voice = option.data
|
|
|
|
|
|
selectedVoiceId.value = value
|
2026-02-02 22:36:20 +08:00
|
|
|
|
currentVoiceName.value = voice.name
|
2025-12-28 00:19:17 +08:00
|
|
|
|
emit('select', voice)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 试听当前选中的音色
|
|
|
|
|
|
const handlePreviewCurrentVoice = () => {
|
|
|
|
|
|
if (!selectedVoiceId.value) return
|
|
|
|
|
|
|
|
|
|
|
|
const voice = displayedVoices.value.find(v => v.id === selectedVoiceId.value)
|
|
|
|
|
|
if (!voice) return
|
|
|
|
|
|
|
2026-02-02 22:36:20 +08:00
|
|
|
|
currentVoiceName.value = voice.name
|
2025-12-28 00:19:17 +08:00
|
|
|
|
handlePlayVoiceSample(voice)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 处理音色试听
|
|
|
|
|
|
*/
|
|
|
|
|
|
const handlePlayVoiceSample = (voice) => {
|
2026-02-02 22:36:20 +08:00
|
|
|
|
currentVoiceName.value = voice.name
|
2025-12-28 00:19:17 +08:00
|
|
|
|
playVoiceSample(
|
|
|
|
|
|
voice,
|
2026-02-02 22:36:20 +08:00
|
|
|
|
(data) => {
|
|
|
|
|
|
// 提取音频 URL
|
|
|
|
|
|
const url = data.audioUrl || data.objectUrl
|
|
|
|
|
|
if (!url) {
|
|
|
|
|
|
console.error('无效的音频数据格式', data)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
initPlayer(url)
|
2025-12-28 00:19:17 +08:00
|
|
|
|
},
|
|
|
|
|
|
(error) => {
|
|
|
|
|
|
console.error('音频播放失败', error)
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 22:36:20 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 初始化 APlayer
|
|
|
|
|
|
*/
|
|
|
|
|
|
const initPlayer = (url) => {
|
|
|
|
|
|
destroyPlayer()
|
|
|
|
|
|
audioUrl.value = url
|
|
|
|
|
|
|
|
|
|
|
|
nextTick(() => {
|
|
|
|
|
|
if (!playerContainer.value) return
|
|
|
|
|
|
|
|
|
|
|
|
player = new APlayer({
|
|
|
|
|
|
container: playerContainer.value,
|
|
|
|
|
|
autoplay: true,
|
|
|
|
|
|
theme: '#3b82f6',
|
|
|
|
|
|
preload: 'auto',
|
|
|
|
|
|
volume: 0.7,
|
|
|
|
|
|
audio: [{
|
|
|
|
|
|
name: currentVoiceName.value || '语音试听',
|
|
|
|
|
|
artist: '试听',
|
|
|
|
|
|
url: url,
|
|
|
|
|
|
cover: defaultCover
|
|
|
|
|
|
}],
|
|
|
|
|
|
options: {
|
|
|
|
|
|
showDownload: true
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
player.on('ended', () => {
|
|
|
|
|
|
if (audioUrl.value?.startsWith('blob:')) {
|
|
|
|
|
|
URL.revokeObjectURL(audioUrl.value)
|
|
|
|
|
|
}
|
|
|
|
|
|
audioUrl.value = ''
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
player.on('error', (e) => {
|
|
|
|
|
|
console.error('APlayer 播放错误:', e)
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 销毁播放器
|
|
|
|
|
|
*/
|
|
|
|
|
|
const destroyPlayer = () => {
|
|
|
|
|
|
if (player) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
player.destroy()
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error('销毁播放器失败:', e)
|
|
|
|
|
|
}
|
|
|
|
|
|
player = null
|
|
|
|
|
|
}
|
|
|
|
|
|
if (audioUrl.value) {
|
|
|
|
|
|
URL.revokeObjectURL(audioUrl.value)
|
|
|
|
|
|
audioUrl.value = ''
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-28 00:19:17 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 设置要试听的文本(供父组件调用)
|
|
|
|
|
|
* @param {string} text 要试听的文本
|
|
|
|
|
|
*/
|
|
|
|
|
|
const setPreviewText = (text) => {
|
|
|
|
|
|
setText(text)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 设置语速(供父组件调用)
|
|
|
|
|
|
* @param {number} rate 语速倍率
|
|
|
|
|
|
*/
|
|
|
|
|
|
const setPreviewSpeechRate = (rate) => {
|
|
|
|
|
|
setSpeechRate(rate)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
defineExpose({
|
|
|
|
|
|
setPreviewText,
|
|
|
|
|
|
setPreviewSpeechRate
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
onMounted(async () => {
|
|
|
|
|
|
await voiceStore.refresh()
|
|
|
|
|
|
})
|
2026-02-02 22:36:20 +08:00
|
|
|
|
|
|
|
|
|
|
onBeforeUnmount(() => {
|
|
|
|
|
|
destroyPlayer()
|
|
|
|
|
|
})
|
2025-12-28 00:19:17 +08:00
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
|
.voice-selector {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.empty-voices {
|
|
|
|
|
|
padding: 8px 12px;
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
color: var(--color-text-secondary);
|
|
|
|
|
|
background: rgba(0, 0, 0, 0.3);
|
|
|
|
|
|
border: 1px dashed rgba(59, 130, 246, 0.3);
|
|
|
|
|
|
border-radius: var(--radius-card);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 音色选择器和试听按钮的容器 */
|
|
|
|
|
|
.voice-selector-with-preview {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 下拉框样式 */
|
|
|
|
|
|
.voice-select {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 试听按钮样式 */
|
|
|
|
|
|
.preview-button {
|
|
|
|
|
|
height: 32px;
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
}
|
2026-02-02 22:36:20 +08:00
|
|
|
|
|
|
|
|
|
|
/* APlayer 容器样式 */
|
|
|
|
|
|
.aplayer-container {
|
|
|
|
|
|
margin-top: 12px;
|
|
|
|
|
|
}
|
2025-12-28 00:19:17 +08:00
|
|
|
|
</style>
|