ai-clone/frontend-ai/pages/upload-audio/upload-audio.vue

917 lines
20 KiB
Vue
Raw Normal View History

2026-03-05 14:29:21 +08:00
<template>
<!-- #ifdef MP-WEIXIN -->
<!-- 小程序使用原生页面 -->
<view class="upload-container">
<!-- 页面标题 -->
<view class="page-header">
<text class="header-title">创建音色</text>
<text class="header-subtitle">上传音频样本克隆专属音色</text>
</view>
<!-- 音色名称 -->
<view class="form-section">
<view class="form-item">
<text class="form-label">音色名称</text>
<input
class="form-input"
v-model="voiceName"
placeholder="请输入音色名称(如:妈妈的声音)"
maxlength="20"
/>
</view>
</view>
<view class="form-section">
<view class="form-item">
<text class="form-label">使用 CosyVoice v3 plus</text>
<view class="toggle-row">
<switch :checked="useCosyVoice" @change="onUseCosyVoiceChange" />
<text class="toggle-hint">开启后将使用 cosyvoice-v3-plus 创建音色</text>
</view>
</view>
</view>
<view class="tips-section">
<view class="tips-title-row">
<text class="tips-title">📄 参考文案可直接朗读</text>
<button class="switch-btn" @click="switchReferenceText">换一换</button>
</view>
<view class="reference-box">
<text class="reference-text">{{ referenceText }}</text>
</view>
<button class="copy-btn" @click="copyReferenceText">复制文案</button>
</view>
<!-- 录制音频 -->
<view class="upload-section">
<text class="section-title">上传音频样本</text>
<text class="section-tip">可录制建议10-20秒清晰人声</text>
<view v-if="!audioPath" class="upload-mode">
<button class="mode-btn" :disabled="isRecording" @click="startRecord">🎤 录音</button>
</view>
<view v-if="!audioPath" class="upload-box">
<view v-if="isRecording" class="recording-box">
<view class="recording-icon">🔴</view>
<text class="recording-text">录音中... {{ recordDuration }}</text>
<button class="stop-btn" @click="stopRecord">停止录音</button>
</view>
<view v-else>
<view class="upload-icon">🎤</view>
<text class="upload-text">点击录音开始录制</text>
</view>
</view>
<view v-else class="audio-preview">
<view class="audio-info">
<text class="audio-icon">🎵</text>
<text class="audio-name">{{ audioName }}</text>
</view>
<view class="audio-actions">
<button class="action-btn play-btn" @click="playAudio">{{ isPlaying ? '暂停' : '播放' }}</button>
<button class="action-btn delete-btn" @click="deleteAudio">删除</button>
</view>
</view>
</view>
<!-- 提交按钮 -->
<view class="submit-section">
<button
class="submit-btn"
:disabled="!canSubmit || uploading"
@click="handleSubmit"
>
<text v-if="uploading">上传中...</text>
<text v-else>创建音色</text>
</button>
</view>
<!-- 说明 -->
<view class="tips-section">
<text class="tips-title">📝 温馨提示</text>
<text class="tips-item"> 音频时长建议10-20最长60秒</text>
<text class="tips-item"> 必须包含至少3秒连续清晰朗读</text>
<text class="tips-item"> 避免背景音乐噪音或其他人声</text>
<text class="tips-item"> 音频内容建议为普通话朗读</text>
<text class="tips-item"> 创建成功后可在"声音克隆"中使用</text>
</view>
</view>
<!-- #endif -->
<!-- #ifndef MP-WEIXIN -->
<!-- App使用H5页面 -->
<view class="upload-page">
<web-view :src="uploadUrl"></web-view>
</view>
<!-- #endif -->
</template>
<script>
import { API_BASE } from '@/config/api.js';
export default {
data() {
return {
// #ifdef MP-WEIXIN
voiceName: '',
audioPath: '',
audioName: '',
audioSize: 0,
useCosyVoice: false,
referenceTexts: [
'我想和你说说最近的生活。今天的天气很好,我也在努力让自己过得更开心。希望你也一切都好。',
'你好呀,最近我过得还不错。今天我想和你聊聊我的心情,也想听听你的近况。',
'我在一个安静的房间里,正在认真朗读这段文字。希望声音清晰自然,能够帮助创建更好的音色。',
'今天我精神很好,说话也很清楚。我会保持语速适中、情绪自然,尽量减少停顿与杂音。'
],
referenceTextIndex: 0,
uploading: false,
isPlaying: false,
isRecording: false,
recordDuration: 0,
recordTimer: null,
recorderManager: null,
innerAudioContext: null
// #endif
};
},
computed: {
// #ifdef MP-WEIXIN
canSubmit() {
return this.voiceName.trim() && this.audioPath;
},
referenceText() {
const list = this.referenceTexts || [];
return list[this.referenceTextIndex] || '';
},
// #endif
// #ifndef MP-WEIXIN
uploadUrl() {
const token = uni.getStorageSync('token') || '';
const userId = uni.getStorageSync('userId') || '';
// 生产默认:使用 API_BASE如手动指定则读取存储
const apiBase = uni.getStorageSync('apiBase') || (typeof API_BASE !== 'undefined' ? API_BASE : '');
return `/static/upload.html?token=${encodeURIComponent(token)}&userId=${encodeURIComponent(userId)}&apiBase=${encodeURIComponent(apiBase)}`;
}
// #endif
},
// #ifndef MP-WEIXIN
onShow() {
// APP版本在显示时检查登录
const userId = uni.getStorageSync('userId');
const token = uni.getStorageSync('token');
if (!userId || !token) {
uni.showModal({
title: '需要登录',
content: '请先登录后再创建音色',
showCancel: false,
success: () => {
uni.reLaunch({
url: '/pages/login/login'
});
}
});
}
},
// #endif
// #ifdef MP-WEIXIN
onLoad() {
// 检查登录状态
this.checkLogin();
// 初始化录音管理器
this.initRecorder();
},
onUnload() {
if (this.innerAudioContext) {
this.innerAudioContext.stop();
this.innerAudioContext.destroy();
}
if (this.recordTimer) {
clearInterval(this.recordTimer);
}
if (this.recorderManager && this.isRecording) {
this.recorderManager.stop();
}
},
methods: {
onUseCosyVoiceChange(e) {
this.useCosyVoice = !!(e && e.detail && e.detail.value);
},
copyReferenceText() {
uni.setClipboardData({
data: this.referenceText,
success: () => {
uni.showToast({
title: '已复制',
icon: 'success'
});
}
});
},
switchReferenceText() {
const list = this.referenceTexts || [];
if (list.length <= 1) {
return;
}
this.referenceTextIndex = (this.referenceTextIndex + 1) % list.length;
},
// 检查登录状态
checkLogin() {
const userId = uni.getStorageSync('userId');
const token = uni.getStorageSync('token');
if (!userId || !token) {
uni.showModal({
title: '需要登录',
content: '请先登录后再创建音色',
showCancel: false,
success: () => {
uni.reLaunch({
url: '/pages/login/login'
});
}
});
return false;
}
return true;
},
// 初始化录音管理器
initRecorder() {
this.recorderManager = uni.getRecorderManager();
// 录音开始
this.recorderManager.onStart(() => {
console.log('录音开始');
this.isRecording = true;
this.recordDuration = 0;
this.recordTimer = setInterval(() => {
this.recordDuration++;
// 超过60秒自动停止
if (this.recordDuration >= 60) {
this.stopRecord();
}
}, 1000);
});
// 录音停止
this.recorderManager.onStop((res) => {
console.log('录音停止', res);
this.isRecording = false;
if (this.recordTimer) {
clearInterval(this.recordTimer);
this.recordTimer = null;
}
// 检查录音时长
if (this.recordDuration < 3) {
uni.showToast({
title: '录音时长至少3秒',
icon: 'none'
});
return;
}
this.audioPath = res.tempFilePath;
this.audioName = `录音_${this.recordDuration}秒.mp3`;
uni.showToast({
title: '录音完成',
icon: 'success'
});
});
// 录音错误
this.recorderManager.onError((err) => {
console.error('录音错误:', err);
this.isRecording = false;
if (this.recordTimer) {
clearInterval(this.recordTimer);
this.recordTimer = null;
}
uni.showToast({
title: '录音失败,请重试',
icon: 'none'
});
});
},
// 选择音频文件
chooseAudioFile() {
// 先检查登录
if (!this.checkLogin()) {
return;
}
uni.chooseMessageFile({
count: 1,
type: 'file',
extension: ['.mp3', '.wav', '.m4a', '.aac'],
success: (res) => {
const file = res.tempFiles && res.tempFiles[0];
if (!file) {
uni.showToast({
title: '选择文件失败',
icon: 'none'
});
return;
}
if (file.size > 10 * 1024 * 1024) {
uni.showToast({
title: '文件大小不能超过10MB',
icon: 'none'
});
return;
}
this.audioPath = file.path;
this.audioName = file.name || 'audio';
this.audioSize = file.size || 0;
this.recordDuration = 0;
uni.showToast({
title: '已选择音频',
icon: 'success'
});
},
fail: (err) => {
console.error('选择文件失败:', err);
uni.showToast({
title: err.errMsg || '选择文件失败',
icon: 'none'
});
}
});
},
// 开始录音
startRecord() {
// 先检查登录
if (!this.checkLogin()) {
return;
}
// 确保录音管理器已初始化
if (!this.recorderManager) {
this.initRecorder();
}
// 先检查权限状态
uni.getSetting({
success: (res) => {
if (res.authSetting['scope.record']) {
// 已授权,直接开始录音
this.doStartRecord();
} else if (res.authSetting['scope.record'] === false) {
// 已拒绝,引导用户去设置
uni.showModal({
title: '需要录音权限',
content: '请在设置中开启录音权限',
confirmText: '去设置',
success: (modalRes) => {
if (modalRes.confirm) {
uni.openSetting({
success: (settingRes) => {
if (settingRes.authSetting['scope.record']) {
this.doStartRecord();
}
}
});
}
}
});
} else {
// 未授权过,请求授权
uni.authorize({
scope: 'scope.record',
success: () => {
this.doStartRecord();
},
fail: () => {
uni.showModal({
title: '需要录音权限',
content: '请允许使用麦克风进行录音',
confirmText: '去设置',
success: (modalRes) => {
if (modalRes.confirm) {
uni.openSetting();
}
}
});
}
});
}
},
fail: () => {
// 获取设置失败,直接尝试录音
this.doStartRecord();
}
});
},
// 执行录音
doStartRecord() {
if (this.recorderManager) {
try {
this.recorderManager.start({
duration: 60000, // 最长60秒
sampleRate: 16000,
numberOfChannels: 1,
encodeBitRate: 96000,
format: 'mp3'
});
} catch (err) {
console.error('启动录音失败:', err);
uni.showModal({
title: '时光意境',
content: '麦克风被其他应用占用\n\n请关闭其他使用麦克风的应用',
confirmText: '确定',
showCancel: false
});
}
} else {
uni.showToast({
title: '录音初始化失败',
icon: 'none'
});
}
},
// 停止录音
stopRecord() {
if (this.recorderManager && this.isRecording) {
this.recorderManager.stop();
}
},
// 播放音频
playAudio() {
if (!this.innerAudioContext) {
this.innerAudioContext = uni.createInnerAudioContext();
this.innerAudioContext.src = this.audioPath;
this.innerAudioContext.onPlay(() => {
this.isPlaying = true;
});
this.innerAudioContext.onPause(() => {
this.isPlaying = false;
});
this.innerAudioContext.onEnded(() => {
this.isPlaying = false;
});
this.innerAudioContext.onError((err) => {
console.error('音频播放失败:', err);
this.isPlaying = false;
uni.showToast({
title: '播放失败',
icon: 'none'
});
});
}
if (this.isPlaying) {
this.innerAudioContext.pause();
} else {
this.innerAudioContext.play();
}
},
// 删除音频
deleteAudio() {
if (this.innerAudioContext) {
this.innerAudioContext.stop();
this.innerAudioContext.destroy();
this.innerAudioContext = null;
}
this.audioPath = '';
this.audioName = '';
this.isPlaying = false;
},
// 提交创建
handleSubmit() {
if (this.uploading) {
return;
}
if (!this.canSubmit) {
uni.showToast({
title: !this.voiceName.trim() ? '请填写音色名称' : '请先录制音频',
icon: 'none'
});
return;
}
const userId = uni.getStorageSync('userId');
if (!userId) {
uni.showToast({
title: '请先登录',
icon: 'none'
});
setTimeout(() => {
uni.navigateTo({
url: '/pages/login/login'
});
}, 1500);
return;
}
const doUpload = () => {
this.uploading = true;
const token = uni.getStorageSync('token') || '';
// 为上传的文件生成一个标准的文件名
const timestamp = Date.now();
const fileName = `recording_${timestamp}.mp3`;
const createEndpoint = this.useCosyVoice ? '/api/voice/cosy-create' : '/api/voice/create';
uni.uploadFile({
url: `${API_BASE}${createEndpoint}`,
filePath: this.audioPath,
name: 'audio',
fileName: fileName, // 指定文件名
formData: {
name: this.voiceName,
displayName: this.voiceName
},
header: {
'X-User-Id': userId,
'Authorization': token ? `Bearer ${token}` : ''
},
success: (res) => {
console.log('上传响应:', res);
try {
const data = typeof res.data === 'string' ? JSON.parse(res.data) : res.data;
console.log('解析后的数据:', data);
console.log('状态码:', res.statusCode);
if (res.statusCode === 200 && data.success) {
uni.showToast({
title: '音色创建成功',
icon: 'success',
duration: 2000
});
setTimeout(() => {
uni.navigateBack();
}, 2000);
return;
}
if (res.statusCode === 402) {
uni.showModal({
title: '需要付费',
content: (data && data.message) ? data.message : '免费次数已用完,请先完成支付',
showCancel: false
});
return;
}
console.error('服务器错误:', data && (data.message || data.error) ? (data.message || data.error) : `服务器错误 (${res.statusCode})`, data);
uni.showModal({
title: '创建失败',
content: (data && data.message) ? data.message : '创建失败,请重试',
showCancel: false
});
} catch (err) {
console.error('处理响应失败:', err);
uni.showModal({
title: '创建失败',
content: '创建失败,请重试',
showCancel: false
});
}
},
fail: (err) => {
console.error('上传失败:', err);
uni.showModal({
title: '创建失败',
content: '网络请求失败,请检查网络连接',
showCancel: false
});
},
complete: () => {
this.uploading = false;
}
});
};
doUpload();
}
}
// #endif
};
</script>
<style lang="scss" scoped>
// #ifdef MP-WEIXIN
/* 小程序样式 */
.upload-container {
min-height: 100vh;
background: linear-gradient(135deg, #FDF8F2 0%, #F5EDE0 100%);
padding: 30upx;
}
.page-header {
text-align: center;
margin-bottom: 40upx;
.header-title {
display: block;
font-size: 44upx;
font-weight: 700;
color: #333;
margin-bottom: 15upx;
}
.header-subtitle {
display: block;
font-size: 26upx;
color: #999;
}
}
.upload-mode {
display: flex;
gap: 20upx;
margin: 15upx 0 25upx;
}
.mode-btn {
flex: 1;
height: 80upx;
border-radius: 15upx;
font-size: 28upx;
background: #F5F5F5;
color: #333;
}
.toggle-row {
display: flex;
align-items: center;
gap: 20upx;
}
.toggle-hint {
font-size: 24upx;
color: #999;
}
.reference-box {
background: #F5F5F5;
border-radius: 15upx;
padding: 20upx;
margin-top: 10upx;
}
.reference-text {
font-size: 26upx;
color: #333;
line-height: 1.6;
}
.copy-btn {
margin-top: 20upx;
height: 80upx;
border-radius: 15upx;
font-size: 28upx;
background: #6D8B8B;
color: white;
}
.tips-title-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.switch-btn {
height: 60upx;
line-height: 60upx;
padding: 0 20upx;
border-radius: 12upx;
font-size: 24upx;
background: #F5F5F5;
color: #666;
}
.form-section {
background: white;
border-radius: 20upx;
padding: 30upx;
margin-bottom: 30upx;
box-shadow: 0 4upx 20upx rgba(0, 0, 0, 0.05);
}
.form-item {
.form-label {
display: block;
font-size: 28upx;
color: #666;
margin-bottom: 15upx;
font-weight: 600;
}
.form-input {
width: 100%;
height: 80upx;
background: #F5F5F5;
border-radius: 15upx;
padding: 0 20upx;
font-size: 28upx;
color: #333;
}
}
.upload-section {
background: white;
border-radius: 20upx;
padding: 30upx;
margin-bottom: 30upx;
box-shadow: 0 4upx 20upx rgba(0, 0, 0, 0.05);
.section-title {
display: block;
font-size: 28upx;
color: #666;
margin-bottom: 10upx;
font-weight: 600;
}
.section-tip {
display: block;
font-size: 24upx;
color: #999;
margin-bottom: 25upx;
}
}
.upload-box {
border: 2upx dashed #8B7355;
border-radius: 20upx;
padding: 60upx 30upx;
text-align: center;
background: #FAFAFA;
.upload-icon {
font-size: 80upx;
margin-bottom: 20upx;
}
.upload-text {
display: block;
font-size: 28upx;
color: #8B7355;
}
.recording-box {
display: flex;
flex-direction: column;
align-items: center;
.recording-icon {
font-size: 80upx;
margin-bottom: 20upx;
animation: pulse 1.5s ease-in-out infinite;
}
.recording-text {
display: block;
font-size: 28upx;
color: #FF6B6B;
margin-bottom: 30upx;
font-weight: 600;
}
.stop-btn {
width: 200upx;
height: 70upx;
background: #FF6B6B;
color: white;
border-radius: 35upx;
font-size: 28upx;
border: none;
box-shadow: 0 4upx 15upx rgba(255, 107, 107, 0.3);
}
}
}
@keyframes pulse {
0%, 100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.1);
opacity: 0.8;
}
}
.audio-preview {
background: #F5F5F5;
border-radius: 20upx;
padding: 25upx;
.audio-info {
display: flex;
align-items: center;
margin-bottom: 20upx;
.audio-icon {
font-size: 40upx;
margin-right: 15upx;
}
.audio-name {
flex: 1;
font-size: 26upx;
color: #333;
}
}
.audio-actions {
display: flex;
gap: 15upx;
.action-btn {
flex: 1;
height: 60upx;
line-height: 60upx;
border-radius: 10upx;
font-size: 26upx;
border: none;
}
.play-btn {
background: #8B7355;
color: white;
}
.delete-btn {
background: #FF6B6B;
color: white;
}
}
}
.submit-section {
margin-bottom: 30upx;
}
.submit-btn {
width: 100%;
height: 90upx;
background: linear-gradient(135deg, #8B7355 0%, #6D8B8B 100%);
border-radius: 45upx;
color: white;
font-size: 32upx;
font-weight: 600;
border: none;
box-shadow: 0 8upx 20upx rgba(139, 115, 85, 0.3);
&[disabled] {
opacity: 0.5;
box-shadow: none;
}
}
.tips-section {
background: rgba(255, 255, 255, 0.8);
border-radius: 20upx;
padding: 25upx;
.tips-title {
display: block;
font-size: 26upx;
color: #8B7355;
font-weight: 600;
margin-bottom: 15upx;
}
.tips-item {
display: block;
font-size: 24upx;
color: #666;
line-height: 2;
padding-left: 10upx;
}
}
// #endif
// #ifndef MP-WEIXIN
/* App样式 */
.upload-page {
width: 100%;
height: 100vh;
}
// #endif
</style>