功能:可以克隆用户的音色进行使用

This commit is contained in:
xiao12feng8 2026-02-01 13:39:28 +08:00
parent 451906a9ac
commit 774b12516f
7 changed files with 7099 additions and 9 deletions

View File

@ -328,3 +328,167 @@ def list_available_voices_for_lover(
), ),
msg="获取可用音色成功", msg="获取可用音色成功",
) )
# ===== 音色克隆相关 =====
class VoiceCloneRequest(BaseModel):
audio_url: str
voice_name: str
gender: str # male/female
model_config = ConfigDict(from_attributes=True)
class VoiceCloneResponse(BaseModel):
voice_id: str
status: str
message: str
model_config = ConfigDict(from_attributes=True)
class VoiceCloneStatusResponse(BaseModel):
voice_id: str
status: str # PENDING, OK, UNDEPLOYED, FAILED
voice_library_id: Optional[int] = None
model_config = ConfigDict(from_attributes=True)
@router.post("/voices/clone", response_model=ApiResponse[VoiceCloneResponse])
def clone_voice(
payload: VoiceCloneRequest,
db: Session = Depends(get_db),
user: AuthedUser = Depends(get_current_user),
):
"""
克隆音色用户上传音频文件系统调用 CosyVoice 克隆音色
"""
from ..cosyvoice_clone import create_voice_from_url
# 验证音色名称长度CosyVoice 限制 prefix <= 10 字符)
if len(payload.voice_name) > 10:
raise HTTPException(status_code=400, detail="音色名称不能超过10个字符")
# 验证性别
if payload.gender not in ["male", "female"]:
raise HTTPException(status_code=400, detail="性别必须是 male 或 female")
try:
# 调用克隆服务
voice_id = create_voice_from_url(
audio_url=payload.audio_url,
prefix=payload.voice_name,
target_model="cosyvoice-v2"
)
return success_response(
VoiceCloneResponse(
voice_id=voice_id,
status="PENDING",
message="音色克隆任务已创建,请稍后查询状态"
)
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"克隆失败: {str(e)}")
@router.get("/voices/clone/{voice_id}/status", response_model=ApiResponse[VoiceCloneStatusResponse])
def get_clone_status(
voice_id: str,
db: Session = Depends(get_db),
user: AuthedUser = Depends(get_current_user),
):
"""
查询克隆音色的状态
"""
from ..cosyvoice_clone import query_voice
try:
info = query_voice(voice_id)
status = info.get("status", "UNKNOWN")
# 如果状态是 OK检查是否已保存到数据库
voice_library_id = None
if status == "OK":
existing = (
db.query(VoiceLibrary)
.filter(VoiceLibrary.voice_code == voice_id)
.first()
)
if existing:
voice_library_id = existing.id
return success_response(
VoiceCloneStatusResponse(
voice_id=voice_id,
status=status,
voice_library_id=voice_library_id
)
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"查询失败: {str(e)}")
@router.post("/voices/clone/{voice_id}/save", response_model=ApiResponse[dict])
def save_cloned_voice(
voice_id: str,
db: Session = Depends(get_db),
user: AuthedUser = Depends(get_current_user),
):
"""
将克隆成功的音色保存到音色库
"""
from ..cosyvoice_clone import query_voice
try:
# 查询音色状态
info = query_voice(voice_id)
status = info.get("status")
if status != "OK":
raise HTTPException(status_code=400, detail=f"音色状态为 {status},无法保存")
# 检查是否已存在
existing = (
db.query(VoiceLibrary)
.filter(VoiceLibrary.voice_code == voice_id)
.first()
)
if existing:
return success_response({"voice_library_id": existing.id, "message": "音色已存在"})
# 获取音色信息
voice_name = info.get("name", "克隆音色")
# 获取用户的恋人信息以确定性别
lover = db.query(Lover).filter(Lover.user_id == user.id).first()
gender = lover.gender if lover else "female"
# 保存到数据库
new_voice = VoiceLibrary(
name=voice_name,
gender=gender,
style_tag="克隆音色",
avatar_url=None,
sample_audio_url=None,
tts_model_id="cosyvoice-v2",
is_default=False,
voice_code=voice_id,
is_owned=True,
price_gold=0
)
db.add(new_voice)
db.flush()
return success_response({
"voice_library_id": new_voice.id,
"message": "音色保存成功"
})
except HTTPException:
raise
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=f"保存失败: {str(e)}")

View File

@ -994,8 +994,9 @@
}); });
}, },
back() { back() {
uni.navigateBack({ // 使 reLaunch
delta: 1, uni.reLaunch({
url: '/pages/index/index'
}); });
}, },
setUp() { setUp() {

View File

@ -5,6 +5,12 @@
<view class="list_head fa sb"> <view class="list_head fa sb">
<image src="/static/images/timbre_logo.png" mode="widthFix"></image>{{ voicesInfo }} <image src="/static/images/timbre_logo.png" mode="widthFix"></image>{{ voicesInfo }}
</view> </view>
<!-- 克隆音色按钮 -->
<view class="clone-voice-btn" @click="showCloneModal">
<text>🎤 克隆我的音色</text>
</view>
<view class="list_content"> <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" <view class="list_module fa sb" v-for="(item, index) in configVoicesList.voices" :key="index" v-show="item.price_gold == 0"
@click="selectTimbre(index)"> @click="selectTimbre(index)">
@ -37,6 +43,42 @@
</view> </view>
</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>
</view> </view>
</template> </template>
@ -64,9 +106,18 @@ export default {
voice_id: '', voice_id: '',
}, },
selectedItemIndex: -1, // selectedItemIndex: -1, //
configVoicesList: [], configVoicesList: { voices: [] }, //
voicesInfo: '', voicesInfo: '',
currentAudioContext: null, // currentAudioContext: null, //
//
cloneModalVisible: false,
cloneVoiceName: '',
audioFile: null,
audioUrl: '',
cloning: false,
cloneStatus: '',
cloneVoiceId: '',
baseURLPy: 'http://127.0.0.1:8000',
} }
}, },
onLoad(options) { onLoad(options) {
@ -215,6 +266,247 @@ export default {
console.log(this.form) console.log(this.form)
this.loverVoice() 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)
};
} }
} }
} }
@ -353,4 +645,145 @@ page {
background: linear-gradient(135deg, #9F47FF 0%, #0053FA 100%); background: linear-gradient(135deg, #9F47FF 0%, #0053FA 100%);
border-radius: 12rpx; 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> </style>

View File

@ -74,7 +74,31 @@
title: '图片加载中...' title: '图片加载中...'
}); });
// // #ifdef H5
// H5 使 URL
const topImageUrl = 'https://nvlovers.oss-cn-qingdao.aliyuncs.com/uploads/20251226/cb61a8209c59166e5a56e9c5c470e8f1.png';
const qrCodeUrl = 'https://nvlovers.oss-cn-qingdao.aliyuncs.com/uploads/20251226/54a0da02080e49dbf2d6412791c58281.png';
this.topImageLocalPath = topImageUrl;
this.qrCodeLocalPath = qrCodeUrl;
//
uni.getImageInfo({
src: topImageUrl,
success: (res) => {
this.topImageRatio = res.width / res.height;
resolve();
},
fail: (err) => {
console.error('获取图片信息失败:', err);
this.topImageRatio = 3;
resolve();
}
});
// #endif
// #ifndef H5
// H5
Promise.all([ Promise.all([
this.downloadImage('https://nvlovers.oss-cn-qingdao.aliyuncs.com/uploads/20251226/cb61a8209c59166e5a56e9c5c470e8f1.png'), this.downloadImage('https://nvlovers.oss-cn-qingdao.aliyuncs.com/uploads/20251226/cb61a8209c59166e5a56e9c5c470e8f1.png'),
this.downloadImage('https://nvlovers.oss-cn-qingdao.aliyuncs.com/uploads/20251226/54a0da02080e49dbf2d6412791c58281.png') this.downloadImage('https://nvlovers.oss-cn-qingdao.aliyuncs.com/uploads/20251226/54a0da02080e49dbf2d6412791c58281.png')
@ -86,14 +110,12 @@
uni.getImageInfo({ uni.getImageInfo({
src: topImagePath, src: topImagePath,
success: (res) => { success: (res) => {
//
this.topImageRatio = res.width / res.height; this.topImageRatio = res.width / res.height;
resolve(); resolve();
}, },
fail: (err) => { fail: (err) => {
console.error('获取图片信息失败:', err); console.error('获取图片信息失败:', err);
// 使 this.topImageRatio = 3;
this.topImageRatio = 3; // 3:1
resolve(); resolve();
} }
}); });
@ -101,6 +123,7 @@
console.error('下载图片失败:', err); console.error('下载图片失败:', err);
reject(err); reject(err);
}); });
// #endif
}); });
}, },

File diff suppressed because it is too large Load Diff

View File

@ -1,8 +1,9 @@
1. 将密码校验删除,因为无法生成模型,用最简单的方法来尝试这些内容。 1. 将密码校验删除,因为无法生成模型,用最简单的方法来尝试这些内容。
2. 将Hbuilder的AppId更换成自己的原本的保留(__UNI__1F3C178)。还是无法正常编译将下面的插件注释掉不用Agora-RTC音视频插件和AudioRecode录音插件。
- [ ] 增加tab栏但是还没有加上对应的功能 - [ ] 增加tab栏但是还没有加上对应的功能
3. 增加聊天背景选择功能,会员可以自定义背景 3. 增加聊天背景选择功能,会员可以自定义背景
4. 增加恋人消息编辑功能,更新**数据库**,加上一些编辑消息、编辑时间相关字段。用户编辑消息之后恋人不回答,只会更新记忆和摘要的数据库,下一次回答的时候会重新引用这个更新后的记忆。 4. 增加恋人消息编辑功能,更新**数据库**,加上一些编辑消息、编辑时间相关字段。用户编辑消息之后恋人不回答,只会更新记忆和摘要的数据库,下一次回答的时候会重新引用这个更新后的记忆。
- [ ] 礼物、换装、音色样式更改,但是还未更新数据库 - [ ] 礼物、换装、音色样式更改,但是还未更新数据库
5. 恋人消息回复增加思考中... 5. 恋人消息回复增加思考中...
6. - [ ] 恋人消息回复和消息编辑都需要测试
6. 将Hbuilder的AppId更换成自己的原本的保留(__UNI__1F3C178)。还是无法正常编译将下面的插件注释掉不用Agora-RTC音视频插件和AudioRecode录音插件。
- [ ] 克隆音色API填写然后给你测试