Ai_GirlFriend/xuniYou/pages/create/timbre.vue
2026-02-01 13:39:28 +08:00

789 lines
17 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>
<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>
<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">支持 mp3、wav 格式</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>
</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: [] }, // 修复:初始化为对象
voicesInfo: '',
currentAudioContext: null, // 添加当前音频上下文实例
// 克隆音色相关
cloneModalVisible: false,
cloneVoiceName: '',
audioFile: null,
audioUrl: '',
cloning: false,
cloneStatus: '',
cloneVoiceId: '',
baseURLPy: 'http://127.0.0.1:8000',
}
},
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)
};
}
}
}
</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;
}
</style>