Happy_language/app/static/js/voice_test.js
2025-09-15 02:45:50 +08:00

715 lines
22 KiB
JavaScript
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.

/**
* 语音克隆测试页面 JavaScript
* 核心功能:语音样本采集 → 识别 → 克隆 → 对比
*/
// 全局变量
let loadingModal = null;
let mediaRecorder = null;
let audioChunks = [];
let uploadedAudioPath = null;
let recognizedText = "";
let sampleAudioUrl = null;
let recordedAudioBlob = null;
// 当前工作流程状态
let currentStep = 1;
// DOM加载完成后初始化
document.addEventListener('DOMContentLoaded', function() {
initializeComponents();
bindEvents();
loadAvailableVoices();
updateStepIndicators();
});
/**
* 初始化组件
*/
function initializeComponents() {
loadingModal = new bootstrap.Modal(document.getElementById('loadingModal'));
}
/**
* 绑定事件
*/
function bindEvents() {
// 连接测试
document.getElementById('test-connection-btn').addEventListener('click', testConnection);
// 语音样本采集
document.getElementById('voice-sample-form').addEventListener('submit', uploadVoiceSample);
document.getElementById('voice-sample-upload').addEventListener('change', handleFileSelect);
document.getElementById('start-recording').addEventListener('click', startRecording);
document.getElementById('stop-recording').addEventListener('click', stopRecording);
// 语音克隆生成
document.getElementById('clone-generation-form').addEventListener('submit', generateClonedVoice);
document.getElementById('clone-random-seed').addEventListener('click', () => getRandomSeed('clone-seed'));
// 高级功能
document.getElementById('preset-voice-form').addEventListener('submit', generatePresetVoice);
document.getElementById('natural-control-form').addEventListener('submit', generateNaturalControl);
// 其他
document.getElementById('clear-log').addEventListener('click', clearLog);
}
/**
* 更新步骤指示器
*/
function updateStepIndicators() {
for (let i = 1; i <= 4; i++) {
const indicator = document.getElementById(`step-${i}-indicator`);
indicator.classList.remove('active', 'completed');
if (i < currentStep) {
indicator.classList.add('completed');
} else if (i === currentStep) {
indicator.classList.add('active');
}
}
// 更新连接线
document.querySelectorAll('.step-line').forEach((line, index) => {
if (index + 1 < currentStep) {
line.classList.add('completed');
} else {
line.classList.remove('completed');
}
});
}
/**
* 跳转到指定步骤
*/
function goToStep(step) {
currentStep = step;
updateStepIndicators();
// 启用/禁用相应按钮
if (step >= 3) {
document.getElementById('generate-clone-btn').disabled = false;
}
}
/**
* 重置工作流程
*/
function resetWorkflow() {
currentStep = 1;
updateStepIndicators();
// 清空数据
uploadedAudioPath = null;
recognizedText = "";
sampleAudioUrl = null;
recordedAudioBlob = null;
// 重置界面
document.getElementById('sample-player').style.display = 'none';
document.getElementById('recognition-result').style.display = 'none';
document.getElementById('recognition-waiting').style.display = 'block';
document.getElementById('comparison-result').style.display = 'none';
document.getElementById('comparison-waiting').style.display = 'block';
// 重置按钮状态
document.getElementById('upload-sample-btn').disabled = true;
document.getElementById('generate-clone-btn').disabled = true;
// 清空文件选择
document.getElementById('voice-sample-upload').value = '';
addLog('工作流程已重置,可以重新开始', 'info');
}
/**
* 显示加载状态
*/
function showLoading(message = '正在处理中...', detail = '请稍候...') {
document.getElementById('loading-message').textContent = message;
document.getElementById('loading-detail').textContent = detail;
loadingModal.show();
}
/**
* 隐藏加载状态
*/
function hideLoading() {
loadingModal.hide();
}
/**
* 添加日志
*/
function addLog(message, type = 'info') {
const logContainer = document.getElementById('test-log');
const timestamp = new Date().toLocaleTimeString();
const logEntry = document.createElement('div');
const colors = {
'info': 'text-primary',
'success': 'text-success',
'error': 'text-danger',
'warning': 'text-warning'
};
logEntry.className = `mb-2 ${colors[type] || 'text-primary'}`;
logEntry.innerHTML = `<small>[${timestamp}]</small> ${message}`;
logContainer.appendChild(logEntry);
logContainer.scrollTop = logContainer.scrollHeight;
}
/**
* 清空日志
*/
function clearLog() {
const logContainer = document.getElementById('test-log');
logContainer.innerHTML = '<p class="text-muted">操作记录将显示在这里...</p>';
}
/**
* 显示错误信息
*/
function showError(message) {
const toast = document.createElement('div');
toast.className = 'toast align-items-center text-white bg-danger border-0 position-fixed top-0 end-0 m-3';
toast.style.zIndex = '9999';
toast.innerHTML = `
<div class="d-flex">
<div class="toast-body">
<i class="fas fa-exclamation-circle me-2"></i>${message}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
`;
document.body.appendChild(toast);
const bsToast = new bootstrap.Toast(toast);
bsToast.show();
setTimeout(() => {
if (toast.parentNode) {
toast.parentNode.removeChild(toast);
}
}, 5000);
}
/**
* 显示成功信息
*/
function showSuccess(message) {
const toast = document.createElement('div');
toast.className = 'toast align-items-center text-white bg-success border-0 position-fixed top-0 end-0 m-3';
toast.style.zIndex = '9999';
toast.innerHTML = `
<div class="d-flex">
<div class="toast-body">
<i class="fas fa-check-circle me-2"></i>${message}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
`;
document.body.appendChild(toast);
const bsToast = new bootstrap.Toast(toast);
bsToast.show();
setTimeout(() => {
if (toast.parentNode) {
toast.parentNode.removeChild(toast);
}
}, 3000);
}
/**
* 创建音频播放URL
*/
function createAudioUrl(audioPath) {
const filename = audioPath.split('/').pop();
return `/voice-test/download-audio/${filename}`;
}
/**
* 测试连接
*/
async function testConnection() {
const btn = document.getElementById('test-connection-btn');
const statusDiv = document.getElementById('connection-status');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>测试中...';
statusDiv.innerHTML = '<span class="text-info">正在测试连接...</span>';
addLog('开始测试CosyVoice服务连接...');
try {
const response = await fetch('/voice-test/api/voice-test/connection', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
});
const result = await response.json();
if (result.success) {
statusDiv.innerHTML = `
<span class="text-success">
<i class="fas fa-check-circle me-2"></i>连接成功
<small class="text-muted">支持语音克隆、识别、自然控制</small>
</span>
`;
addLog(`连接成功!核心功能可用:语音克隆、识别、自然控制`, 'success');
// 更新音色列表
if (result.available_voices) {
updateVoiceOptions(result.available_voices);
}
} else {
statusDiv.innerHTML = `
<span class="text-danger">
<i class="fas fa-times-circle me-2"></i>连接失败: ${result.message}
</span>
`;
addLog(`连接失败: ${result.message}`, 'error');
}
} catch (error) {
statusDiv.innerHTML = `
<span class="text-danger">
<i class="fas fa-times-circle me-2"></i>请求失败: ${error.message}
</span>
`;
addLog(`请求失败: ${error.message}`, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-wifi me-2"></i>测试连接';
}
}
/**
* 加载可用音色
*/
async function loadAvailableVoices() {
try {
const response = await fetch('/voice-test/api/voice-test/voices');
const result = await response.json();
if (result.success) {
updateVoiceOptions(result.voices);
}
} catch (error) {
console.error('加载音色失败:', error);
}
}
/**
* 更新音色选项
*/
function updateVoiceOptions(voices) {
const voiceSelect = document.getElementById('preset-voice');
voiceSelect.innerHTML = '';
voices.forEach(voice => {
const option = document.createElement('option');
option.value = voice;
option.textContent = voice;
voiceSelect.appendChild(option);
});
}
/**
* 获取随机种子
*/
async function getRandomSeed(inputId) {
try {
const response = await fetch('/voice-test/api/voice-test/random-seed');
const result = await response.json();
if (result.success) {
document.getElementById(inputId).value = result.seed;
addLog(`生成随机种子: ${result.seed}`);
}
} catch (error) {
showError('获取随机种子失败');
}
}
/**
* 处理文件选择
*/
function handleFileSelect(e) {
const file = e.target.files[0];
if (file) {
// 重置录音状态
recordedAudioBlob = null;
sampleAudioUrl = null;
addLog(`选择了音频文件: ${file.name} (${(file.size/1024/1024).toFixed(2)} MB)`);
document.getElementById('upload-sample-btn').disabled = false;
document.getElementById('upload-sample-btn').innerHTML = '<i class="fas fa-upload me-2"></i>上传并识别语音';
}
}
/**
* 开始录音
*/
async function startRecording() {
try {
// 重置文件选择
document.getElementById('voice-sample-upload').value = '';
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
sampleRate: 16000, // 设置采样率为16kHz
channelCount: 1, // 单声道
echoCancellation: true,
noiseSuppression: true
}
});
// 创建MediaRecorder明确指定格式
const options = {
mimeType: 'audio/webm;codecs=opus' // 使用webm格式
};
// 检查浏览器支持的格式
if (!MediaRecorder.isTypeSupported(options.mimeType)) {
if (MediaRecorder.isTypeSupported('audio/webm')) {
options.mimeType = 'audio/webm';
} else if (MediaRecorder.isTypeSupported('audio/wav')) {
options.mimeType = 'audio/wav';
} else {
// 使用默认格式
delete options.mimeType;
}
}
mediaRecorder = new MediaRecorder(stream, options);
audioChunks = [];
mediaRecorder.ondataavailable = function(event) {
if (event.data.size > 0) {
audioChunks.push(event.data);
}
};
mediaRecorder.onstop = function() {
// 创建音频Blob
recordedAudioBlob = new Blob(audioChunks, {
type: mediaRecorder.mimeType || 'audio/webm'
});
const audioUrl = URL.createObjectURL(recordedAudioBlob);
// 显示录音预览
const sampleAudio = document.getElementById('sample-audio');
const sampleSource = document.getElementById('sample-audio-source');
sampleSource.src = audioUrl;
sampleAudio.load();
document.getElementById('sample-player').style.display = 'block';
// 启用上传按钮
document.getElementById('upload-sample-btn').disabled = false;
document.getElementById('upload-sample-btn').innerHTML = '<i class="fas fa-upload me-2"></i>上传并识别语音';
// 保存录音数据
sampleAudioUrl = audioUrl;
addLog(`录音完成,格式: ${mediaRecorder.mimeType || 'default'}, 大小: ${(recordedAudioBlob.size/1024).toFixed(1)} KB`, 'success');
};
mediaRecorder.start(100); // 每100ms收集一次数据
// 更新UI
document.getElementById('start-recording').disabled = true;
document.getElementById('stop-recording').disabled = false;
document.getElementById('recording-status').textContent = '正在录音...';
document.getElementById('recording-status').className = 'text-danger';
addLog('开始录音...', 'info');
} catch (error) {
addLog(`录音失败: ${error.message}`, 'error');
showError('录音失败,请检查麦克风权限');
}
}
/**
* 停止录音
*/
function stopRecording() {
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
mediaRecorder.stop();
mediaRecorder.stream.getTracks().forEach(track => track.stop());
}
// 更新UI
document.getElementById('start-recording').disabled = false;
document.getElementById('stop-recording').disabled = true;
document.getElementById('recording-status').textContent = '录音已完成';
document.getElementById('recording-status').className = 'text-success';
addLog('录音停止', 'info');
}
/**
* 上传语音样本并进行识别
*/
async function uploadVoiceSample(e) {
e.preventDefault();
showLoading('正在上传和识别语音...', '包括格式转换和语音识别,请稍候');
addLog('开始上传语音样本进行识别...');
try {
const fileInput = document.getElementById('voice-sample-upload');
const file = fileInput.files[0];
let formData = new FormData();
if (file) {
// 上传文件
formData.append('audio', file);
addLog(`上传文件: ${file.name}`);
} else if (recordedAudioBlob) {
// 上传录音 - 使用正确的文件名和类型
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `recording_${timestamp}.webm`;
formData.append('audio', recordedAudioBlob, filename);
addLog(`上传录音: ${filename}, 大小: ${(recordedAudioBlob.size/1024).toFixed(1)} KB`);
} else {
showError('请选择音频文件或先录音');
hideLoading();
return;
}
// 上传并识别
const response = await fetch('/voice-test/api/voice-test/upload-audio', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
// 保存音频路径
uploadedAudioPath = result.file_path;
recognizedText = result.recognized_text || '';
// 显示识别结果
document.getElementById('recognized-text').value = recognizedText;
document.getElementById('recognition-result').style.display = 'block';
document.getElementById('recognition-waiting').style.display = 'none';
// 更新步骤
goToStep(2);
setTimeout(() => goToStep(3), 1000);
addLog(`语音识别成功: "${recognizedText}"`, 'success');
addLog(`音频处理信息: ${result.file_info?.format || '已转换格式'}`, 'info');
showSuccess('语音样本上传成功AI已识别出内容');
// 保存原始音频用于对比
const originalAudio = document.getElementById('original-audio');
const originalSource = document.getElementById('original-audio-source');
originalSource.src = createAudioUrl(uploadedAudioPath);
originalAudio.load();
} else {
addLog(`语音识别失败: ${result.message}`, 'error');
showError(result.message);
}
} catch (error) {
addLog(`上传出错: ${error.message}`, 'error');
showError('上传失败,请检查网络连接');
} finally {
hideLoading();
}
}
/**
* 生成克隆语音
*/
async function generateClonedVoice(e) {
e.preventDefault();
if (!uploadedAudioPath) {
showError('请先上传语音样本');
return;
}
const text = document.getElementById('clone-text').value.trim();
const seed = parseInt(document.getElementById('clone-seed').value);
const referenceText = document.getElementById('recognized-text').value.trim();
if (!text) {
showError('请输入要合成的文本');
return;
}
showLoading('正在克隆你的声音...', '这是最复杂的步骤,请耐心等待');
addLog(`开始语音克隆 - 目标文本: "${text.substring(0, 20)}..."`);
addLog(`使用音频文件: ${uploadedAudioPath}`);
try {
const response = await fetch('/voice-test/api/voice-test/generate/clone', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
text: text,
reference_audio_path: uploadedAudioPath,
reference_text: referenceText,
seed: seed
})
});
const result = await response.json();
if (result.success) {
// 显示克隆语音
const clonedAudio = document.getElementById('cloned-audio');
const clonedSource = document.getElementById('cloned-audio-source');
clonedSource.src = createAudioUrl(result.audio_url);
clonedAudio.load();
// 显示对比界面
document.getElementById('comparison-result').style.display = 'block';
document.getElementById('comparison-waiting').style.display = 'none';
// 更新到最后步骤
goToStep(4);
addLog(`🎉 语音克隆成功!请对比原声和克隆效果`, 'success');
showSuccess('语音克隆完成!请播放音频对比效果');
} else {
addLog(`语音克隆失败: ${result.message}`, 'error');
showError(result.message || '语音克隆失败,请重试');
}
} catch (error) {
addLog(`克隆出错: ${error.message}`, 'error');
showError('克隆失败,请检查网络连接');
} finally {
hideLoading();
}
}
/**
* 预训练音色语音生成(高级功能)
*/
async function generatePresetVoice(e) {
e.preventDefault();
const text = document.getElementById('preset-text').value.trim();
const voice = document.getElementById('preset-voice').value;
if (!text) {
showError('请输入要合成的文本');
return;
}
showLoading('正在生成预训练音色语音...');
try {
const response = await fetch('/voice-test/api/voice-test/generate/preset', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
text: text,
voice: voice,
seed: 42,
speed: 1.0
})
});
const result = await response.json();
if (result.success) {
const audioSource = document.getElementById('preset-audio-source');
const resultDiv = document.getElementById('preset-result');
audioSource.src = createAudioUrl(result.audio_url);
audioSource.parentElement.load();
resultDiv.style.display = 'block';
addLog(`预训练音色生成成功 - ${voice}`, 'success');
} else {
addLog(`预训练音色生成失败: ${result.message}`, 'error');
showError(result.message);
}
} catch (error) {
addLog(`生成出错: ${error.message}`, 'error');
showError('生成失败,请检查网络连接');
} finally {
hideLoading();
}
}
/**
* 自然语言控制语音生成(高级功能)
*/
async function generateNaturalControl(e) {
e.preventDefault();
const text = document.getElementById('natural-text').value.trim();
const instruction = document.getElementById('natural-instruction').value.trim();
if (!text) {
showError('请输入要合成的文本');
return;
}
if (!instruction) {
showError('请输入语音指令');
return;
}
showLoading('正在生成自然语言控制语音...');
try {
const response = await fetch('/voice-test/api/voice-test/generate/natural', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
text: text,
instruction: instruction,
seed: 42
})
});
const result = await response.json();
if (result.success) {
const audioSource = document.getElementById('natural-audio-source');
const resultDiv = document.getElementById('natural-result');
audioSource.src = createAudioUrl(result.audio_url);
audioSource.parentElement.load();
resultDiv.style.display = 'block';
addLog(`自然语言控制生成成功`, 'success');
} else {
addLog(`自然语言控制生成失败: ${result.message}`, 'error');
showError(result.message);
}
} catch (error) {
addLog(`生成出错: ${error.message}`, 'error');
showError('生成失败,请检查网络连接');
} finally {
hideLoading();
}
}