ai-clone/frontend-ai/pages/upload-audio/upload-audio.vue
2026-03-05 14:29:21 +08:00

917 lines
20 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>
<!-- #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>