Ai_GirlFriend/xuniYou/pages/create/timbre.vue

789 lines
17 KiB
Vue
Raw Normal View History

2026-01-31 19:15:41 +08:00
<template>
<view>
<view class="body">
<view class="list">
<view class="list_head fa sb">
<image src="/static/images/timbre_logo.png" mode="widthFix"></image>{{ voicesInfo }}
</view>
<!-- 克隆音色按钮 -->
<view class="clone-voice-btn" @click="showCloneModal">
<text>🎤 克隆我的音色</text>
</view>
2026-01-31 19:15:41 +08:00
<view class="list_content">
<view class="list_module fa sb" v-for="(item, index) in configVoicesList.voices" :key="index" v-show="item.price_gold == 0"
@click="selectTimbre(index)">
<view class="list_detail fa f1">
<view class="list_image">
<image class="list_avatar" :src="item.avatar_url?global + item.avatar_url:'/static/images/replacement_picture.png'" mode="aspectFill"></image>
<image class="list_voice" @click.stop="playVoice(item.sample_audio_url)"
src="/static/images/timbre_voice.png" mode="widthFix"></image>
</view>
<view class="list_item f1">
<view class="list_name h1">{{ item.name }}</view>
<view class="list_tag h2">{{ item.style_tag }}</view>
</view>
</view>
<view class="list_select faj" >
<image
:src="selectedItemIndex == index ? '/static/images/selectA.png' : '/static/images/select.png'"
mode="widthFix"></image>
</view>
</view>
</view>
</view>
</view>
<view class="opt">
<view class="opt_module">
<view class="opt_data">
<view class="opt_btn faj" @click="confirmSelection">
确定选择
</view>
</view>
</view>
</view>
<!-- 克隆音色弹窗 -->
<view class="clone-modal" v-if="cloneModalVisible" @click="cloneModalVisible = false">
<view class="clone-content" @click.stop>
<view class="clone-title">克隆音色</view>
<view class="clone-desc">上传一段清晰的音频至少3秒AI将克隆您的音色</view>
<input
v-model="cloneVoiceName"
class="clone-input"
placeholder="请输入音色名称最多10个字符"
maxlength="10">
</input>
<view class="clone-upload-area" @click="chooseAudio">
<view v-if="!audioFile" class="upload-placeholder">
<text>📁 点击选择音频文件</text>
<text class="upload-tip">支持 mp3wav 格式</text>
</view>
<view v-else class="upload-success">
<text> {{ audioFile.name }}</text>
</view>
</view>
<view class="clone-status" v-if="cloneStatus">
<text :class="cloneStatusClass">{{ cloneStatusText }}</text>
</view>
<view class="clone-buttons">
<view class="clone-btn cancel" @click="cancelClone">取消</view>
<view class="clone-btn confirm" @click="startClone" :class="{ disabled: cloning }">
{{ cloning ? '克隆中...' : '开始克隆' }}
</view>
</view>
</view>
</view>
2026-01-31 19:15:41 +08:00
</view>
</template>
<script>
import {
ConfigVoices,
LoverVoice,
ConfigVoicesAvailable,
LoverVoiceSimple
} from '@/utils/api.js'
import notHave from '@/components/not-have.vue';
import topSafety from '@/components/top-safety.vue';
import { useVoiceStore } from '@/store/index.js'
export default {
components: {
notHave,
topSafety,
},
data() {
return {
global: this.baseURL,
form: {
gender: '',
voice_id: '',
},
selectedItemIndex: -1, // 默认不选择任何项
configVoicesList: { voices: [] }, // 修复:初始化为对象
2026-01-31 19:15:41 +08:00
voicesInfo: '',
currentAudioContext: null, // 添加当前音频上下文实例
// 克隆音色相关
cloneModalVisible: false,
cloneVoiceName: '',
audioFile: null,
audioUrl: '',
cloning: false,
cloneStatus: '',
cloneVoiceId: '',
baseURLPy: 'http://127.0.0.1:8000',
2026-01-31 19:15:41 +08:00
}
},
onLoad(options) {
console.log('角色声音页面接参:',options)
this.form.gender = options.sex
console.log(this.form)
this.configVoices()
},
methods: {
configVoices() {
let api = ''
if(this.form.gender){
api = ConfigVoices
}else{
api = ConfigVoicesAvailable
}
api({
...this.form
}).then(res => {
if (res.code == 1) {
this.configVoicesList = res.data
if (this.configVoicesList.selected_voice_id != null) {
let found = false;
for (let i = 0; i < this.configVoicesList.voices.length; i++) {
if (this.configVoicesList.voices[i].id == this.configVoicesList.selected_voice_id) {
this.voicesInfo = this.configVoicesList.voices[i].name
this.selectedItemIndex = i
this.form.voice_id = this.configVoicesList.voices[i].id
found = true;
break;
}
}
if (!found) {
// 如果没有找到 selected_voice_id则使用默认值
for (let i = 0; i < this.configVoicesList.voices.length; i++) {
if (this.configVoicesList.voices[i].id == this.configVoicesList.default_voice_id) {
this.voicesInfo = this.configVoicesList.voices[i].name
this.selectedItemIndex = i
this.form.voice_id = this.configVoicesList.voices[i].id
break;
}
}
}
} else {
for (let i = 0; i < this.configVoicesList.voices.length; i++) {
if (this.configVoicesList.voices[i].id == this.configVoicesList.default_voice_id) {
this.voicesInfo = this.configVoicesList.voices[i].name
this.selectedItemIndex = i
this.form.voice_id = this.configVoicesList.voices[i].id
break;
}
}
}
} else {
uni.showToast({
title: res.msg,
icon: 'none',
position: 'top'
})
}
})
},
// 设置声音
loverVoice() {
let api = ''
if(this.form.gender){
api = LoverVoice
}else{
api = LoverVoiceSimple
}
api(this.form).then(res => {
if (res.code == 1) {
uni.showToast({
title: '设置成功',
icon: 'none',
position: 'top'
})
const voiceStore = useVoiceStore()
voiceStore.setVoice(this.form.voice_id, this.voicesInfo)
setTimeout(() => {
uni.navigateBack({
delta: 1
});
}, 1000);
} else {
uni.showToast({
title: res.msg,
icon: 'none',
position: 'top'
})
}
})
},
// 选择音色
selectTimbre(index) {
this.selectedItemIndex = index;
this.voicesInfo = this.configVoicesList.voices[index].name
this.form.voice_id = this.configVoicesList.voices[index].id
},
playVoice(url) {
// 如果当前有正在播放的音频,先停止它
if (this.currentAudioContext) {
this.currentAudioContext.stop();
this.currentAudioContext.destroy(); // 释放资源
}
// 创建新的音频上下文
this.currentAudioContext = uni.createInnerAudioContext();
this.currentAudioContext.src = this.global + url; // 设置音频源
// 播放音频
this.currentAudioContext.play();
// 监听播放结束事件,播放结束后清空当前音频上下文
this.currentAudioContext.onEnded(() => {
console.log('音频播放结束');
if (this.currentAudioContext) {
this.currentAudioContext.destroy();
this.currentAudioContext = null;
}
});
// 监听播放错误事件
this.currentAudioContext.onError((err) => {
console.error('音频播放失败:', err);
if (this.currentAudioContext) {
this.currentAudioContext.destroy();
this.currentAudioContext = null;
}
uni.showToast({
title: '播放失败',
icon: 'none'
});
});
},
confirmSelection() {
console.log(this.form)
console.log(this.selectedItemIndex)
if (this.selectedItemIndex < 0) {
uni.showToast({
title: '请选择音色',
icon: 'none'
});
} else {
console.log(this.form)
this.loverVoice()
}
},
// ===== 克隆音色相关方法 =====
showCloneModal() {
this.cloneModalVisible = true;
this.cloneVoiceName = '';
this.audioFile = null;
this.audioUrl = '';
this.cloneStatus = '';
},
cancelClone() {
this.cloneModalVisible = false;
this.cloning = false;
},
chooseAudio() {
uni.chooseFile({
count: 1,
extension: ['.mp3', '.wav', '.m4a'],
success: (res) => {
this.audioFile = {
path: res.tempFilePaths[0],
name: res.tempFiles[0].name || '音频文件'
};
console.log('选择的音频:', this.audioFile);
},
fail: (err) => {
console.error('选择文件失败:', err);
uni.showToast({
title: '选择文件失败',
icon: 'none'
});
}
});
},
async startClone() {
if (this.cloning) return;
if (!this.cloneVoiceName.trim()) {
uni.showToast({
title: '请输入音色名称',
icon: 'none'
});
return;
}
if (!this.audioFile) {
uni.showToast({
title: '请选择音频文件',
icon: 'none'
});
return;
}
this.cloning = true;
this.cloneStatus = 'uploading';
try {
// 1. 上传音频文件到 OSS
const uploadResult = await this.uploadAudio();
if (!uploadResult) {
throw new Error('上传失败');
}
this.audioUrl = uploadResult;
this.cloneStatus = 'cloning';
// 2. 调用克隆 API
const cloneResult = await this.callCloneAPI();
if (!cloneResult) {
throw new Error('克隆失败');
}
this.cloneVoiceId = cloneResult.voice_id;
this.cloneStatus = 'polling';
// 3. 轮询状态
await this.pollCloneStatus();
} catch (error) {
console.error('克隆失败:', error);
this.cloneStatus = 'failed';
this.cloning = false;
}
},
uploadAudio() {
return new Promise((resolve, reject) => {
uni.uploadFile({
url: this.baseURL + '/api/common/upload',
filePath: this.audioFile.path,
name: 'file',
header: {
token: uni.getStorageSync("token") || "",
},
success: (res) => {
const data = JSON.parse(res.data);
if (data.code === 1) {
resolve(data.data.url);
} else {
reject(new Error(data.msg));
}
},
fail: reject
});
});
},
callCloneAPI() {
return new Promise((resolve, reject) => {
uni.request({
url: this.baseURLPy + '/config/voices/clone',
method: 'POST',
header: {
'Content-Type': 'application/json',
'token': uni.getStorageSync("token") || "",
},
data: {
audio_url: this.audioUrl,
voice_name: this.cloneVoiceName,
gender: this.form.gender || 'female'
},
success: (res) => {
if (res.data.code === 1) {
resolve(res.data.data);
} else {
reject(new Error(res.data.message));
}
},
fail: reject
});
});
},
async pollCloneStatus() {
const maxAttempts = 30; // 最多轮询30次
const interval = 10000; // 每10秒轮询一次
for (let i = 0; i < maxAttempts; i++) {
await new Promise(resolve => setTimeout(resolve, interval));
try {
const status = await this.checkCloneStatus();
if (status === 'OK') {
// 克隆成功,保存到数据库
await this.saveClonedVoice();
this.cloneStatus = 'success';
this.cloning = false;
uni.showToast({
title: '克隆成功!',
icon: 'success'
});
// 刷新音色列表
setTimeout(() => {
this.cloneModalVisible = false;
this.configVoices();
}, 2000);
return;
} else if (status === 'FAILED') {
throw new Error('克隆失败');
}
// 继续轮询
} catch (error) {
console.error('查询状态失败:', error);
this.cloneStatus = 'failed';
this.cloning = false;
return;
}
}
// 超时
this.cloneStatus = 'timeout';
this.cloning = false;
},
checkCloneStatus() {
return new Promise((resolve, reject) => {
uni.request({
url: this.baseURLPy + `/config/voices/clone/${this.cloneVoiceId}/status`,
method: 'GET',
header: {
'token': uni.getStorageSync("token") || "",
},
success: (res) => {
if (res.data.code === 1) {
resolve(res.data.data.status);
} else {
reject(new Error(res.data.message));
}
},
fail: reject
});
});
},
saveClonedVoice() {
return new Promise((resolve, reject) => {
uni.request({
url: this.baseURLPy + `/config/voices/clone/${this.cloneVoiceId}/save`,
method: 'POST',
header: {
'token': uni.getStorageSync("token") || "",
},
success: (res) => {
if (res.data.code === 1) {
resolve(res.data.data);
} else {
reject(new Error(res.data.message));
}
},
fail: reject
});
});
}
},
computed: {
cloneStatusText() {
const statusMap = {
'uploading': '正在上传音频...',
'cloning': '正在克隆音色...',
'polling': '正在生成音色,请稍候...',
'success': '✅ 克隆成功!',
'failed': '❌ 克隆失败',
'timeout': '⏱️ 克隆超时,请稍后重试'
};
return statusMap[this.cloneStatus] || '';
},
cloneStatusClass() {
return {
'status-processing': ['uploading', 'cloning', 'polling'].includes(this.cloneStatus),
'status-success': this.cloneStatus === 'success',
'status-error': ['failed', 'timeout'].includes(this.cloneStatus)
};
2026-01-31 19:15:41 +08:00
}
}
}
</script>
<style>
page {
background: #F6F8FA;
}
</style>
<style>
.body {
position: relative;
padding: 0 40rpx 40rpx 40rpx;
}
.list {
position: relative;
margin: 30rpx 0 0 0;
}
.list_head {
position: relative;
margin: 0 0 30rpx 0;
padding: 28rpx 40rpx;
font-weight: 400;
font-size: 30rpx;
color: #9E9E9E;
line-height: 50rpx;
background: #FFFFFF;
border-radius: 12rpx;
}
.list_head image {
width: 68rpx;
height: 66rpx;
display: block;
border-radius: 100rpx;
}
.list_content {
position: relative;
}
.list_module {
position: relative;
margin: 0 0 30rpx 0;
padding: 28rpx 40rpx;
background: #FFFFFF;
border-radius: 12rpx;
}
.list_detail {
position: relative;
overflow: hidden;
}
.list_image {
position: relative;
}
.list_avatar {
width: 88rpx;
height: 88rpx;
display: block;
flex-shrink: 0;
border-radius: 100rpx;
}
.list_voice {
position: absolute;
right: -10rpx;
bottom: -10rpx;
width: 48rpx;
height: 48rpx;
}
.list_item {
position: relative;
margin: 0 0 0 24rpx;
overflow: hidden;
}
.list_name {
font-weight: 500;
font-size: 32rpx;
color: #333333;
line-height: 50rpx;
}
.list_tag {
font-weight: 400;
font-size: 28rpx;
color: #9E9E9E;
line-height: 50rpx;
}
.list_select {
position: relative;
}
.list_select image {
width: 36rpx;
height: 36rpx;
flex-shrink: 0;
}
.opt {
position: relative;
height: 150rpx;
}
.opt_module {
position: fixed;
padding: 5rpx 0 70rpx 0;
height: 85rpx;
width: 100%;
background: #F6F8FA;
bottom: 0;
left: 0;
box-sizing: content-box;
border-top: 1px solid #f0f0f0;
z-index: 2;
}
.opt_data {
padding: 5rpx 50rpx;
font-size: 28rpx;
}
.opt_btn {
padding: 24rpx 0;
font-weight: 400;
font-size: 32rpx;
color: #FFFFFF;
line-height: 50rpx;
background: linear-gradient(135deg, #9F47FF 0%, #0053FA 100%);
border-radius: 12rpx;
}
/* 克隆音色按钮 */
.clone-voice-btn {
margin: 0 0 30rpx 0;
padding: 24rpx 40rpx;
background: linear-gradient(135deg, #FF6B9D 0%, #C239B3 100%);
border-radius: 12rpx;
text-align: center;
color: #FFFFFF;
font-size: 32rpx;
font-weight: 500;
}
/* 克隆音色弹窗 */
.clone-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.clone-content {
width: 85%;
max-width: 600rpx;
background: #FFFFFF;
border-radius: 20rpx;
padding: 40rpx;
}
.clone-title {
font-size: 36rpx;
font-weight: bold;
color: #333;
text-align: center;
margin-bottom: 20rpx;
}
.clone-desc {
font-size: 26rpx;
color: #999;
text-align: center;
margin-bottom: 30rpx;
line-height: 1.5;
}
.clone-input {
width: 100%;
padding: 20rpx;
border: 2rpx solid #E0E0E0;
border-radius: 10rpx;
font-size: 28rpx;
margin-bottom: 30rpx;
}
.clone-upload-area {
width: 100%;
min-height: 200rpx;
border: 2rpx dashed #9F47FF;
border-radius: 10rpx;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 30rpx;
background: #F9F9F9;
}
.upload-placeholder {
display: flex;
flex-direction: column;
align-items: center;
color: #666;
}
.upload-placeholder text:first-child {
font-size: 32rpx;
margin-bottom: 10rpx;
}
.upload-tip {
font-size: 24rpx;
color: #999;
}
.upload-success {
color: #4CAF50;
font-size: 28rpx;
padding: 20rpx;
text-align: center;
}
.clone-status {
text-align: center;
margin-bottom: 30rpx;
font-size: 28rpx;
}
.status-processing {
color: #2196F3;
}
.status-success {
color: #4CAF50;
}
.status-error {
color: #F44336;
}
.clone-buttons {
display: flex;
justify-content: space-between;
gap: 20rpx;
}
.clone-btn {
flex: 1;
padding: 20rpx;
text-align: center;
border-radius: 10rpx;
font-size: 28rpx;
}
.clone-btn.cancel {
background: #f5f5f5;
color: #666;
}
.clone-btn.confirm {
background: linear-gradient(135deg, #9F47FF 0%, #0053FA 100%);
color: #fff;
}
.clone-btn.disabled {
opacity: 0.6;
}
2026-01-31 19:15:41 +08:00
</style>