817 lines
34 KiB
HTML
817 lines
34 KiB
HTML
|
|
<!DOCTYPE html>
|
|||
|
|
<html lang="zh-CN">
|
|||
|
|
<head>
|
|||
|
|
<meta charset="UTF-8">
|
|||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|||
|
|
<title>上传音频</title>
|
|||
|
|
<style>
|
|||
|
|
* {
|
|||
|
|
margin: 0;
|
|||
|
|
padding: 0;
|
|||
|
|
box-sizing: border-box;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
body {
|
|||
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|||
|
|
background: linear-gradient(135deg, #FDF8F2 0%, #F5EDE3 100%);
|
|||
|
|
padding: 20px;
|
|||
|
|
min-height: 100vh;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.container {
|
|||
|
|
max-width: 600px;
|
|||
|
|
margin: 0 auto;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.upload-card {
|
|||
|
|
background: white;
|
|||
|
|
border-radius: 20px;
|
|||
|
|
padding: 30px;
|
|||
|
|
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.1);
|
|||
|
|
margin-bottom: 20px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.title {
|
|||
|
|
font-size: 24px;
|
|||
|
|
font-weight: bold;
|
|||
|
|
color: #8B7355;
|
|||
|
|
margin-bottom: 20px;
|
|||
|
|
text-align: center;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.upload-area {
|
|||
|
|
border: 3px dashed #D4B996;
|
|||
|
|
border-radius: 15px;
|
|||
|
|
padding: 40px 20px;
|
|||
|
|
text-align: center;
|
|||
|
|
background: rgba(212, 185, 150, 0.05);
|
|||
|
|
cursor: pointer;
|
|||
|
|
transition: all 0.3s;
|
|||
|
|
margin-bottom: 20px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.upload-area:hover {
|
|||
|
|
border-color: #8B7355;
|
|||
|
|
background: rgba(139, 115, 85, 0.1);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.upload-area.dragover {
|
|||
|
|
border-color: #8B7355;
|
|||
|
|
background: rgba(139, 115, 85, 0.15);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.upload-icon {
|
|||
|
|
font-size: 48px;
|
|||
|
|
margin-bottom: 10px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.upload-text {
|
|||
|
|
font-size: 16px;
|
|||
|
|
color: #666;
|
|||
|
|
margin-bottom: 10px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.upload-hint {
|
|||
|
|
font-size: 12px;
|
|||
|
|
color: #999;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.file-input {
|
|||
|
|
display: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.form-group {
|
|||
|
|
margin-bottom: 20px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.label {
|
|||
|
|
display: block;
|
|||
|
|
font-size: 14px;
|
|||
|
|
font-weight: 600;
|
|||
|
|
color: #333;
|
|||
|
|
margin-bottom: 8px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.input {
|
|||
|
|
width: 100%;
|
|||
|
|
padding: 12px 15px;
|
|||
|
|
border: 2px solid #e0e0e0;
|
|||
|
|
border-radius: 10px;
|
|||
|
|
font-size: 16px;
|
|||
|
|
transition: all 0.3s;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.input:focus {
|
|||
|
|
outline: none;
|
|||
|
|
border-color: #8B7355;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.hint {
|
|||
|
|
font-size: 12px;
|
|||
|
|
color: #999;
|
|||
|
|
margin-top: 5px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.file-info {
|
|||
|
|
background: #e8f5e9;
|
|||
|
|
border-left: 4px solid #4caf50;
|
|||
|
|
padding: 15px;
|
|||
|
|
border-radius: 8px;
|
|||
|
|
margin-bottom: 20px;
|
|||
|
|
display: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.file-info.show {
|
|||
|
|
display: block;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.file-name {
|
|||
|
|
font-size: 14px;
|
|||
|
|
color: #2e7d32;
|
|||
|
|
font-weight: 600;
|
|||
|
|
margin-bottom: 5px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.file-size {
|
|||
|
|
font-size: 12px;
|
|||
|
|
color: #66bb6a;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tips-card {
|
|||
|
|
background: rgba(212, 185, 150, 0.1);
|
|||
|
|
padding: 20px;
|
|||
|
|
border-radius: 15px;
|
|||
|
|
border-left: 4px solid #8B7355;
|
|||
|
|
margin-bottom: 20px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tips-title {
|
|||
|
|
font-size: 14px;
|
|||
|
|
font-weight: bold;
|
|||
|
|
color: #8B7355;
|
|||
|
|
margin-bottom: 10px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tips-list {
|
|||
|
|
font-size: 13px;
|
|||
|
|
color: #555;
|
|||
|
|
line-height: 1.8;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tip-item {
|
|||
|
|
margin-bottom: 5px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.btn {
|
|||
|
|
width: 100%;
|
|||
|
|
padding: 15px;
|
|||
|
|
border: none;
|
|||
|
|
border-radius: 12px;
|
|||
|
|
font-size: 16px;
|
|||
|
|
font-weight: bold;
|
|||
|
|
cursor: pointer;
|
|||
|
|
transition: all 0.3s;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.btn-secondary {
|
|||
|
|
background: linear-gradient(135deg, #9e9e9e 0%, #757575 100%);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.btn-secondary:hover {
|
|||
|
|
background: linear-gradient(135deg, #757575 0%, #616161 100%);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.btn-danger {
|
|||
|
|
background: linear-gradient(135deg, #f44336 0%, #d32f2f 100%);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.btn-danger:hover {
|
|||
|
|
background: linear-gradient(135deg, #d32f2f 0%, #c62828 100%);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.btn-primary {
|
|||
|
|
background: linear-gradient(135deg, #8B7355 0%, #6D8B8B 100%);
|
|||
|
|
color: white;
|
|||
|
|
box-shadow: 0 4px 15px rgba(139, 115, 85, 0.3);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.btn-primary:hover {
|
|||
|
|
transform: translateY(-2px);
|
|||
|
|
box-shadow: 0 6px 20px rgba(139, 115, 85, 0.4);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.btn-primary:active {
|
|||
|
|
transform: translateY(0);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.btn-primary:disabled {
|
|||
|
|
opacity: 0.5;
|
|||
|
|
cursor: not-allowed;
|
|||
|
|
transform: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.message {
|
|||
|
|
padding: 15px;
|
|||
|
|
border-radius: 10px;
|
|||
|
|
margin-bottom: 20px;
|
|||
|
|
font-size: 14px;
|
|||
|
|
display: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.message.show {
|
|||
|
|
display: block;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.message.success {
|
|||
|
|
background: #d4edda;
|
|||
|
|
color: #155724;
|
|||
|
|
border-left: 4px solid #28a745;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.message.error {
|
|||
|
|
background: #f8d7da;
|
|||
|
|
color: #721c24;
|
|||
|
|
border-left: 4px solid #dc3545;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.progress-bar {
|
|||
|
|
width: 100%;
|
|||
|
|
height: 8px;
|
|||
|
|
background: #e0e0e0;
|
|||
|
|
border-radius: 4px;
|
|||
|
|
overflow: hidden;
|
|||
|
|
margin-bottom: 20px;
|
|||
|
|
display: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.progress-bar.show {
|
|||
|
|
display: block;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.progress-fill {
|
|||
|
|
height: 100%;
|
|||
|
|
background: linear-gradient(90deg, #8B7355 0%, #6D8B8B 100%);
|
|||
|
|
width: 0%;
|
|||
|
|
transition: width 0.3s;
|
|||
|
|
}
|
|||
|
|
</style>
|
|||
|
|
</head>
|
|||
|
|
<body>
|
|||
|
|
<div class="container">
|
|||
|
|
<div class="upload-card">
|
|||
|
|
<h1 class="title">🎤 录制音色</h1>
|
|||
|
|
|
|||
|
|
<div id="message" class="message"></div>
|
|||
|
|
|
|||
|
|
<!-- 示例文案 -->
|
|||
|
|
<div style="background: #FFF9F0; border-radius: 12px; padding: 20px; margin-bottom: 20px; border: 2px solid #FFE4C4;">
|
|||
|
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
|
|||
|
|
<div style="font-size: 14px; font-weight: 600; color: #8B7355;">📝 参考文案(照着读)</div>
|
|||
|
|
<button id="refreshTextBtn" style="background: none; border: none; color: #8B7355; cursor: pointer; font-size: 20px; padding: 5px;" title="换一句">🔄</button>
|
|||
|
|
</div>
|
|||
|
|
<div id="sampleText" style="font-size: 16px; line-height: 1.8; color: #333; padding: 12px; background: white; border-radius: 8px; border-left: 4px solid #8B7355;"></div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 录音模式 -->
|
|||
|
|
<div id="recordMode">
|
|||
|
|
<div class="upload-area" style="padding: 40px;">
|
|||
|
|
<div class="upload-icon" id="recordIcon">🎙️</div>
|
|||
|
|
<div class="upload-text" id="recordText">点击开始录音</div>
|
|||
|
|
<div class="upload-hint" id="recordHint">建议录制10-20秒清晰人声</div>
|
|||
|
|
<button id="recordBtn" class="btn btn-primary" style="margin-top: 20px;">开始录音</button>
|
|||
|
|
<div id="recordTimer" style="font-size: 24px; font-weight: bold; margin-top: 10px; display: none;">00:00</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div id="fileInfo" class="file-info">
|
|||
|
|
<div class="file-name" id="fileName"></div>
|
|||
|
|
<div class="file-size" id="fileSize"></div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="form-group">
|
|||
|
|
<label class="label">音色显示名称</label>
|
|||
|
|
<input type="text" id="displayName" class="input" placeholder="请输入音色显示名称(如:妈妈的声音)" maxlength="20">
|
|||
|
|
<div class="hint">💡 音色名称将显示在音色列表中</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="form-group">
|
|||
|
|
<label class="label" style="display: flex; align-items: center; gap: 8px;">
|
|||
|
|
<input type="checkbox" id="useCosyVoice" style="width: 16px; height: 16px;">
|
|||
|
|
使用 CosyVoice-v3-plus 模型
|
|||
|
|
</label>
|
|||
|
|
<div class="hint">选中方可进行方言复刻</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="progress-bar" id="progressBar">
|
|||
|
|
<div class="progress-fill" id="progressFill"></div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<button id="uploadBtn" class="btn btn-primary" disabled>🎤 创建音色</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="tips-card">
|
|||
|
|
<div class="tips-title">📋 音频要求</div>
|
|||
|
|
<div class="tips-list">
|
|||
|
|
<div class="tip-item">✓ 推荐时长:10-20秒(最长60秒)</div>
|
|||
|
|
<div class="tip-item">✓ 必须包含至少3秒连续清晰朗读</div>
|
|||
|
|
<div class="tip-item">✓ 避免背景音乐、噪音或其他人声</div>
|
|||
|
|
<div class="tip-item">✓ 采样率 ≥ 24kHz,单声道</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<script>
|
|||
|
|
// 配置API地址 - 从URL参数获取或使用本地地址
|
|||
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|||
|
|
// 默认指向生产环境,可通过 ?apiBase=... 覆盖
|
|||
|
|
const API_BASE_URL = urlParams.get('apiBase') || 'http://115.190.167.176:20002';
|
|||
|
|
|
|||
|
|
const fileInfo = document.getElementById('fileInfo');
|
|||
|
|
const fileName = document.getElementById('fileName');
|
|||
|
|
const fileSize = document.getElementById('fileSize');
|
|||
|
|
const displayName = document.getElementById('displayName');
|
|||
|
|
const uploadBtn = document.getElementById('uploadBtn');
|
|||
|
|
const useCosyVoice = document.getElementById('useCosyVoice');
|
|||
|
|
const message = document.getElementById('message');
|
|||
|
|
const progressBar = document.getElementById('progressBar');
|
|||
|
|
const progressFill = document.getElementById('progressFill');
|
|||
|
|
|
|||
|
|
let selectedFile = null;
|
|||
|
|
let mediaRecorder = null;
|
|||
|
|
let audioChunks = [];
|
|||
|
|
let recordingTimer = null;
|
|||
|
|
let recordingSeconds = 0;
|
|||
|
|
|
|||
|
|
// 示例文案列表(注重口齿清晰和发音准确)
|
|||
|
|
const sampleTexts = [
|
|||
|
|
'春天来了,桃花盛开,柳树发芽,燕子从南方飞回来了。公园里到处都是游玩的人群,孩子们在草地上奔跑嬉戏,老人们在树荫下悠闲地聊天下棋。',
|
|||
|
|
'清晨的阳光透过窗帘洒进房间,鸟儿在枝头欢快地歌唱。我起床后打开窗户,深深地吸了一口新鲜空气,感觉整个人都精神了许多。',
|
|||
|
|
'超市里的商品琳琅满目,蔬菜水果新鲜诱人。我推着购物车慢慢挑选,买了西红柿、黄瓜、苹果和香蕉,还有一些日常生活用品。',
|
|||
|
|
'图书馆里安静极了,只能听到翻书的沙沙声。学生们都在认真地看书学习,有的在做笔记,有的在查阅资料,整个环境充满了浓厚的学习氛围。',
|
|||
|
|
'傍晚时分,夕阳西下,天边的云彩被染成了金黄色和橘红色。我站在阳台上眺望远方,看着城市的灯光逐渐亮起,心情格外平静。',
|
|||
|
|
'周末的时候,我喜欢去公园散步。沿着湖边的小路慢慢走,看着湖面上波光粼粼,偶尔有几只野鸭游过,感觉特别惬意舒适。',
|
|||
|
|
'厨房里飘来阵阵香味,妈妈正在准备晚餐。她熟练地切菜、炒菜,不一会儿就做好了几道色香味俱全的家常菜,让人食欲大增。',
|
|||
|
|
'秋天是收获的季节,田野里金黄的稻谷随风摇曳。农民伯伯们忙着收割庄稼,脸上洋溢着丰收的喜悦,到处都是一片繁忙景象。',
|
|||
|
|
'下雨了,雨点打在窗户上发出滴答滴答的声音。街道上的行人撑着五颜六色的雨伞匆匆走过,汽车开过水洼溅起一片片水花。',
|
|||
|
|
'冬天的早晨特别冷,呼出的气都变成了白雾。人们都穿上了厚厚的棉衣,戴着帽子和手套,小心翼翼地走在结冰的路面上。',
|
|||
|
|
'音乐会上,钢琴家的手指在琴键上灵活地跳动,优美的旋律在大厅里回荡。观众们都静静地聆听,完全沉浸在美妙的音乐世界中。',
|
|||
|
|
'博物馆里陈列着各种各样的文物和艺术品。游客们仔细观看每一件展品,认真阅读旁边的说明文字,不时发出赞叹的声音。',
|
|||
|
|
'火车站里人来人往,非常热闹。有的人拖着行李箱急匆匆地赶路,有的人在候车室里耐心等待,广播里不断传来列车到站和发车的通知。',
|
|||
|
|
'花园里种满了各种各样的花草树木。玫瑰、牡丹、菊花竞相开放,蝴蝶和蜜蜂在花丛中飞舞,整个花园充满了生机和活力。',
|
|||
|
|
'夜晚的星空格外美丽,繁星点点,银河横跨天际。我躺在草地上仰望星空,试图找出北斗七星和其他熟悉的星座,感受宇宙的浩瀚无垠。'
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
// 随机显示示例文案
|
|||
|
|
function showRandomText() {
|
|||
|
|
const sampleTextEl = document.getElementById('sampleText');
|
|||
|
|
const randomIndex = Math.floor(Math.random() * sampleTexts.length);
|
|||
|
|
sampleTextEl.textContent = sampleTexts[randomIndex];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 页面加载时显示随机文案
|
|||
|
|
showRandomText();
|
|||
|
|
|
|||
|
|
// 刷新按钮点击事件
|
|||
|
|
document.getElementById('refreshTextBtn').addEventListener('click', () => {
|
|||
|
|
showRandomText();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
|
|||
|
|
// 转换音频为WAV格式
|
|||
|
|
async function convertToWav(blob) {
|
|||
|
|
// 如果Blob已经是WAV格式,直接返回
|
|||
|
|
if (blob.type === 'audio/wav' || blob.type === 'audio/x-wav') {
|
|||
|
|
return blob;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
|||
|
|
const arrayBuffer = await blob.arrayBuffer();
|
|||
|
|
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
|
|||
|
|
|
|||
|
|
// 转换为WAV
|
|||
|
|
const wavBuffer = audioBufferToWav(audioBuffer);
|
|||
|
|
return new Blob([wavBuffer], { type: 'audio/wav' });
|
|||
|
|
} catch (err) {
|
|||
|
|
throw new Error('音频解码失败: ' + err.message);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// AudioBuffer转WAV
|
|||
|
|
function audioBufferToWav(buffer) {
|
|||
|
|
const numChannels = buffer.numberOfChannels;
|
|||
|
|
const sampleRate = buffer.sampleRate;
|
|||
|
|
const format = 1; // PCM
|
|||
|
|
const bitDepth = 16;
|
|||
|
|
|
|||
|
|
const bytesPerSample = bitDepth / 8;
|
|||
|
|
const blockAlign = numChannels * bytesPerSample;
|
|||
|
|
|
|||
|
|
const dataLength = buffer.length * blockAlign;
|
|||
|
|
const bufferLength = 44 + dataLength;
|
|||
|
|
|
|||
|
|
const arrayBuffer = new ArrayBuffer(bufferLength);
|
|||
|
|
const view = new DataView(arrayBuffer);
|
|||
|
|
|
|||
|
|
// 写入WAV文件头
|
|||
|
|
let pos = 0;
|
|||
|
|
|
|||
|
|
// "RIFF" chunk descriptor
|
|||
|
|
writeString(view, pos, 'RIFF'); pos += 4;
|
|||
|
|
view.setUint32(pos, bufferLength - 8, true); pos += 4;
|
|||
|
|
writeString(view, pos, 'WAVE'); pos += 4;
|
|||
|
|
|
|||
|
|
// "fmt " sub-chunk
|
|||
|
|
writeString(view, pos, 'fmt '); pos += 4;
|
|||
|
|
view.setUint32(pos, 16, true); pos += 4; // SubChunk1Size (16 for PCM)
|
|||
|
|
view.setUint16(pos, format, true); pos += 2; // AudioFormat (1 for PCM)
|
|||
|
|
view.setUint16(pos, numChannels, true); pos += 2;
|
|||
|
|
view.setUint32(pos, sampleRate, true); pos += 4;
|
|||
|
|
view.setUint32(pos, sampleRate * blockAlign, true); pos += 4; // ByteRate
|
|||
|
|
view.setUint16(pos, blockAlign, true); pos += 2;
|
|||
|
|
view.setUint16(pos, bitDepth, true); pos += 2;
|
|||
|
|
|
|||
|
|
// "data" sub-chunk
|
|||
|
|
writeString(view, pos, 'data'); pos += 4;
|
|||
|
|
view.setUint32(pos, dataLength, true); pos += 4;
|
|||
|
|
|
|||
|
|
// 写入音频数据
|
|||
|
|
const channels = [];
|
|||
|
|
for (let i = 0; i < numChannels; i++) {
|
|||
|
|
channels.push(buffer.getChannelData(i));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let offset = 0;
|
|||
|
|
while (offset < buffer.length) {
|
|||
|
|
for (let i = 0; i < numChannels; i++) {
|
|||
|
|
let sample = channels[i][offset];
|
|||
|
|
// 限制在 [-1, 1] 范围内
|
|||
|
|
sample = Math.max(-1, Math.min(1, sample));
|
|||
|
|
// 转换为16位整数
|
|||
|
|
sample = sample < 0 ? sample * 0x8000 : sample * 0x7FFF;
|
|||
|
|
view.setInt16(pos, sample, true);
|
|||
|
|
pos += 2;
|
|||
|
|
}
|
|||
|
|
offset++;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return arrayBuffer;
|
|||
|
|
|
|||
|
|
function writeString(view, offset, string) {
|
|||
|
|
for (let i = 0; i < string.length; i++) {
|
|||
|
|
view.setUint8(offset + i, string.charCodeAt(i));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 录音功能
|
|||
|
|
const recordBtn = document.getElementById('recordBtn');
|
|||
|
|
const recordTimer = document.getElementById('recordTimer');
|
|||
|
|
const recordIcon = document.getElementById('recordIcon');
|
|||
|
|
const recordText = document.getElementById('recordText');
|
|||
|
|
const recordHint = document.getElementById('recordHint');
|
|||
|
|
|
|||
|
|
recordBtn.addEventListener('click', async () => {
|
|||
|
|
if (!mediaRecorder || mediaRecorder.state === 'inactive') {
|
|||
|
|
// 开始录音
|
|||
|
|
try {
|
|||
|
|
// 检查浏览器是否支持录音
|
|||
|
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
|||
|
|
alert('您的浏览器不支持录音功能,请使用文件上传');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 首次使用时,提示用户即将请求麦克风权限
|
|||
|
|
const hasShownPermissionHint = sessionStorage.getItem('micPermissionHintShown');
|
|||
|
|
if (!hasShownPermissionHint) {
|
|||
|
|
const userConfirm = confirm('📢 需要使用麦克风录音\n\n点击"确定"后,请在弹出的权限请求中允许使用麦克风。\n\n如果不想授权,可以使用"上传文件"功能。');
|
|||
|
|
if (!userConfirm) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
sessionStorage.setItem('micPermissionHintShown', 'true');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const stream = await navigator.mediaDevices.getUserMedia({
|
|||
|
|
audio: {
|
|||
|
|
echoCancellation: true,
|
|||
|
|
noiseSuppression: true,
|
|||
|
|
sampleRate: 48000
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 尝试不同的MIME类型
|
|||
|
|
let mimeType = '';
|
|||
|
|
const mimeTypes = [
|
|||
|
|
'audio/webm;codecs=opus',
|
|||
|
|
'audio/webm',
|
|||
|
|
'audio/ogg;codecs=opus',
|
|||
|
|
'audio/mp4',
|
|||
|
|
''
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
for (const type of mimeTypes) {
|
|||
|
|
if (type === '' || MediaRecorder.isTypeSupported(type)) {
|
|||
|
|
mimeType = type;
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const options = mimeType ? { mimeType } : {};
|
|||
|
|
mediaRecorder = new MediaRecorder(stream, options);
|
|||
|
|
|
|||
|
|
audioChunks = [];
|
|||
|
|
recordingSeconds = 0;
|
|||
|
|
|
|||
|
|
mediaRecorder.ondataavailable = (e) => {
|
|||
|
|
if (e.data.size > 0) {
|
|||
|
|
audioChunks.push(e.data);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
mediaRecorder.onstop = async () => {
|
|||
|
|
// 使用浏览器原生格式创建Blob
|
|||
|
|
const audioBlob = new Blob(audioChunks, { type: mediaRecorder.mimeType });
|
|||
|
|
|
|||
|
|
// 检查录音时长
|
|||
|
|
if (recordingSeconds < 3) {
|
|||
|
|
alert('❌ 录音时长不足\n\n录音时长太短,未满足最小时长要求(至少3秒)。\n\n请重新录制,建议录制10-20秒清晰人声。');
|
|||
|
|
// 停止所有音轨
|
|||
|
|
stream.getTracks().forEach(track => track.stop());
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 检查录音数据是否有效
|
|||
|
|
if (audioChunks.length === 0 || audioBlob.size === 0) {
|
|||
|
|
alert('❌ 录音数据无效\n\n未能捕获到有效的音频数据,可能是录音质量不够或麦克风问题。\n\n请检查麦克风设置后重新录制,或使用"上传文件"功能。');
|
|||
|
|
// 停止所有音轨
|
|||
|
|
stream.getTracks().forEach(track => track.stop());
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 转换为WAV格式
|
|||
|
|
try {
|
|||
|
|
const wavBlob = await convertToWav(audioBlob);
|
|||
|
|
|
|||
|
|
// 再次检查转换后的文件大小
|
|||
|
|
if (wavBlob.size < 1000) {
|
|||
|
|
alert('❌ 录音质量不够\n\n音频文件过小,可能录音质量不够或未满足最小时长要求。\n\n请确保:\n• 录音时长至少3秒\n• 麦克风正常工作\n• 环境安静,声音清晰\n\n建议重新录制10-20秒清晰人声。');
|
|||
|
|
stream.getTracks().forEach(track => track.stop());
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const audioFile = new File([wavBlob], `recording_${Date.now()}.wav`, { type: 'audio/wav' });
|
|||
|
|
handleFile(audioFile);
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error('音频转换错误:', err);
|
|||
|
|
alert('❌ 录音处理失败\n\n音频转换失败,可能是录音质量不够或格式不支持。\n\n错误信息:' + err.message + '\n\n请重新录制,或使用"上传文件"功能。');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 停止所有音轨
|
|||
|
|
stream.getTracks().forEach(track => track.stop());
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 每100ms收集一次数据
|
|||
|
|
mediaRecorder.start(100);
|
|||
|
|
recordBtn.textContent = '停止录音';
|
|||
|
|
recordBtn.classList.add('btn-danger');
|
|||
|
|
recordBtn.classList.remove('btn-primary');
|
|||
|
|
recordIcon.textContent = '🔴';
|
|||
|
|
recordText.textContent = '正在录音...';
|
|||
|
|
recordHint.textContent = '点击停止录音按钮结束';
|
|||
|
|
recordTimer.style.display = 'block';
|
|||
|
|
|
|||
|
|
// 开始计时
|
|||
|
|
recordingTimer = setInterval(() => {
|
|||
|
|
recordingSeconds++;
|
|||
|
|
const minutes = Math.floor(recordingSeconds / 60);
|
|||
|
|
const seconds = recordingSeconds % 60;
|
|||
|
|
recordTimer.textContent = `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
|||
|
|
}, 1000);
|
|||
|
|
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error('录音错误:', err);
|
|||
|
|
console.error('错误名称:', err.name);
|
|||
|
|
console.error('错误消息:', err.message);
|
|||
|
|
|
|||
|
|
// 检查错误消息内容,判断是否为权限问题
|
|||
|
|
const errorMsg = (err.message || '').toLowerCase();
|
|||
|
|
const errorName = (err.name || '').toLowerCase();
|
|||
|
|
|
|||
|
|
const isPermissionError =
|
|||
|
|
errorName.includes('notallowed') ||
|
|||
|
|
errorName.includes('permission') ||
|
|||
|
|
errorMsg.includes('permission') ||
|
|||
|
|
errorMsg.includes('denied') ||
|
|||
|
|
errorMsg.includes('not allowed') ||
|
|||
|
|
errorMsg.includes('user denied');
|
|||
|
|
|
|||
|
|
const isDeviceError =
|
|||
|
|
errorName.includes('notfound') ||
|
|||
|
|
errorName.includes('devicesnotfound') ||
|
|||
|
|
errorMsg.includes('not found');
|
|||
|
|
|
|||
|
|
const isOccupiedError =
|
|||
|
|
errorName.includes('notreadable') ||
|
|||
|
|
errorName.includes('trackstart') ||
|
|||
|
|
errorMsg.includes('not readable') ||
|
|||
|
|
errorMsg.includes('occupied') ||
|
|||
|
|
errorMsg.includes('in use');
|
|||
|
|
|
|||
|
|
// 根据错误类型给出友好提示
|
|||
|
|
if (isPermissionError) {
|
|||
|
|
alert('❌ 需要麦克风权限\n\n录音功能需要麦克风权限才能使用。\n\n请前往:\n手机设置 → 应用管理 → 时光意境 → 权限管理 → 开启麦克风权限');
|
|||
|
|
} else if (isDeviceError) {
|
|||
|
|
alert('❌ 未找到麦克风设备\n\n请检查:\n1. 麦克风是否已连接\n2. 应用是否有麦克风权限\n\n如需开启权限,请前往:\n手机设置 → 应用管理 → 时光意境 → 权限管理');
|
|||
|
|
} else if (isOccupiedError) {
|
|||
|
|
alert('❌ 麦克风被占用或权限未开启\n\n可能原因:\n1. 其他应用正在使用麦克风\n2. 应用权限未开启\n\n请前往:\n手机设置 → 应用管理 → 时光意境 → 权限管理 → 开启麦克风权限');
|
|||
|
|
} else if (errorName.includes('security')) {
|
|||
|
|
alert('❌ 安全限制:无法访问麦克风\n\n请检查应用麦克风权限是否已开启。\n\n前往:\n手机设置 → 应用管理 → 时光意境 → 权限管理');
|
|||
|
|
} else {
|
|||
|
|
alert('❌ 无法访问麦克风\n\n错误信息:' + err.message + '\n\n请前往系统设置检查应用麦克风权限:\n手机设置 → 应用管理 → 时光意境 → 权限管理');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
// 停止录音
|
|||
|
|
mediaRecorder.stop();
|
|||
|
|
recordBtn.textContent = '开始录音';
|
|||
|
|
recordBtn.classList.remove('btn-danger');
|
|||
|
|
recordBtn.classList.add('btn-primary');
|
|||
|
|
recordIcon.textContent = '🎙️';
|
|||
|
|
recordText.textContent = '录音完成';
|
|||
|
|
recordHint.textContent = '可以重新录制或上传';
|
|||
|
|
recordTimer.style.display = 'none';
|
|||
|
|
clearInterval(recordingTimer);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 移除文件上传相关事件监听
|
|||
|
|
|
|||
|
|
// 处理文件
|
|||
|
|
function handleFile(file) {
|
|||
|
|
if (!file) return;
|
|||
|
|
|
|||
|
|
// 验证文件类型
|
|||
|
|
const validTypes = ['audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/m4a', 'audio/aac', 'audio/x-m4a'];
|
|||
|
|
const fileExt = file.name.split('.').pop().toLowerCase();
|
|||
|
|
const validExts = ['mp3', 'wav', 'm4a', 'aac'];
|
|||
|
|
|
|||
|
|
if (!validTypes.includes(file.type) && !validExts.includes(fileExt)) {
|
|||
|
|
showMessage('error', '请选择音频文件(MP3, WAV, M4A, AAC)');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 验证文件大小
|
|||
|
|
if (file.size > 10 * 1024 * 1024) {
|
|||
|
|
showMessage('error', '文件大小不能超过 10MB');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
selectedFile = file;
|
|||
|
|
|
|||
|
|
// 显示文件信息
|
|||
|
|
fileName.textContent = `📄 ${file.name}`;
|
|||
|
|
fileSize.textContent = `大小: ${(file.size / 1024).toFixed(2)} KB`;
|
|||
|
|
fileInfo.classList.add('show');
|
|||
|
|
|
|||
|
|
// 启用上传按钮
|
|||
|
|
updateUploadButton();
|
|||
|
|
|
|||
|
|
showMessage('success', `已选择文件: ${file.name}`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 监听显示名称输入
|
|||
|
|
displayName.addEventListener('input', updateUploadButton);
|
|||
|
|
|
|||
|
|
// 更新上传按钮状态
|
|||
|
|
function updateUploadButton() {
|
|||
|
|
uploadBtn.disabled = !selectedFile || !displayName.value.trim();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 上传文件
|
|||
|
|
uploadBtn.addEventListener('click', async () => {
|
|||
|
|
if (!selectedFile || !displayName.value.trim()) {
|
|||
|
|
showMessage('error', '请选择文件并输入音色名称');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
uploadBtn.disabled = true;
|
|||
|
|
uploadBtn.textContent = '⏳ 上传中...';
|
|||
|
|
progressBar.classList.add('show');
|
|||
|
|
|
|||
|
|
// 获取登录信息 - 从URL参数或localStorage
|
|||
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|||
|
|
const token = urlParams.get('token') || localStorage.getItem('token') || '';
|
|||
|
|
const userId = urlParams.get('userId') || localStorage.getItem('userId') || '';
|
|||
|
|
|
|||
|
|
console.log('[Upload] token:', token ? '有' : '无', 'userId:', userId || '无');
|
|||
|
|
|
|||
|
|
if (!token || !userId) {
|
|||
|
|
showMessage('error', '请先登录。token: ' + (token ? '有' : '无') + ', userId: ' + (userId || '无'));
|
|||
|
|
uploadBtn.disabled = false;
|
|||
|
|
uploadBtn.textContent = '🎤 创建音色';
|
|||
|
|
progressBar.classList.remove('show');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const formData = new FormData();
|
|||
|
|
formData.append('audio', selectedFile);
|
|||
|
|
const voiceName = displayName.value.trim();
|
|||
|
|
formData.append('name', voiceName);
|
|||
|
|
formData.append('displayName', voiceName);
|
|||
|
|
|
|||
|
|
const xhr = new XMLHttpRequest();
|
|||
|
|
|
|||
|
|
// 上传进度
|
|||
|
|
xhr.upload.addEventListener('progress', (e) => {
|
|||
|
|
if (e.lengthComputable) {
|
|||
|
|
const percent = (e.loaded / e.total) * 100;
|
|||
|
|
progressFill.style.width = percent + '%';
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 上传完成
|
|||
|
|
xhr.addEventListener('load', () => {
|
|||
|
|
if (xhr.status === 200) {
|
|||
|
|
try {
|
|||
|
|
const data = JSON.parse(xhr.responseText);
|
|||
|
|
if (data.success) {
|
|||
|
|
// 使用弹窗提示成功
|
|||
|
|
alert(`✅ 音色创建成功!\n\n音色名称:${displayName.value}\n\n您可以在"声音克隆"中使用该音色了。`);
|
|||
|
|
|
|||
|
|
// 重置表单
|
|||
|
|
selectedFile = null;
|
|||
|
|
displayName.value = '';
|
|||
|
|
fileInfo.classList.remove('show');
|
|||
|
|
progressBar.classList.remove('show');
|
|||
|
|
progressFill.style.width = '0%';
|
|||
|
|
uploadBtn.textContent = '🎤 创建音色';
|
|||
|
|
updateUploadButton();
|
|||
|
|
|
|||
|
|
// 通知父页面刷新并返回
|
|||
|
|
if (window.parent) {
|
|||
|
|
window.parent.postMessage({ type: 'voiceCreated' }, '*');
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
showMessage('error', data.message || '创建失败');
|
|||
|
|
uploadBtn.disabled = false;
|
|||
|
|
uploadBtn.textContent = '🎤 创建音色';
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
showMessage('error', '服务器响应格式错误');
|
|||
|
|
uploadBtn.disabled = false;
|
|||
|
|
uploadBtn.textContent = '🎤 创建音色';
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
showMessage('error', `上传失败: HTTP ${xhr.status}`);
|
|||
|
|
uploadBtn.disabled = false;
|
|||
|
|
uploadBtn.textContent = '🎤 创建音色';
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 上传失败
|
|||
|
|
xhr.addEventListener('error', () => {
|
|||
|
|
showMessage('error', '网络错误,上传失败');
|
|||
|
|
uploadBtn.disabled = false;
|
|||
|
|
uploadBtn.textContent = '🎤 创建音色';
|
|||
|
|
progressBar.classList.remove('show');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
let endpoint = `${API_BASE_URL}/api/voice/create`;
|
|||
|
|
if (useCosyVoice && useCosyVoice.checked) {
|
|||
|
|
endpoint = `${API_BASE_URL}/api/voice/cosy-create`;
|
|||
|
|
}
|
|||
|
|
xhr.open('POST', endpoint);
|
|||
|
|
|
|||
|
|
// 设置认证请求头
|
|||
|
|
if (token) {
|
|||
|
|
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
|||
|
|
}
|
|||
|
|
if (userId) {
|
|||
|
|
xhr.setRequestHeader('X-User-Id', userId);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
xhr.send(formData);
|
|||
|
|
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('上传错误:', error);
|
|||
|
|
showMessage('error', '上传失败: ' + error.message);
|
|||
|
|
uploadBtn.disabled = false;
|
|||
|
|
uploadBtn.textContent = '🎤 创建音色';
|
|||
|
|
progressBar.classList.remove('show');
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 显示消息
|
|||
|
|
function showMessage(type, text) {
|
|||
|
|
message.className = `message ${type} show`;
|
|||
|
|
message.textContent = text;
|
|||
|
|
|
|||
|
|
setTimeout(() => {
|
|||
|
|
message.classList.remove('show');
|
|||
|
|
}, 5000);
|
|||
|
|
}
|
|||
|
|
</script>
|
|||
|
|
</body>
|
|||
|
|
</html>
|