Files
python_script/flask-dev-api/templates/rvc.html
DelLevin-Home db033c661f 11111
2026-06-17 01:51:11 +08:00

825 lines
43 KiB
HTML
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.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="{{ url_for('static', filename='font-awesome.css') }}">
<title>RVC 变声</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: #f5f5f5; color: #333; height: 100vh; overflow: hidden; display: flex;
}
.left-panel {
width: 50%; flex-shrink: 0; display: flex; flex-direction: column;
border-right: 1px solid #e8e8e8; background: #fff; height: 100vh; overflow-y: auto;
}
.left-panel::-webkit-scrollbar { width: 4px; }
.left-panel::-webkit-scrollbar-thumb { background: #ddd; border-radius: 2px; }
.input-section { padding: 20px 24px 28px; }
.header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 2px; }
.header h1 { font-size: 18px; font-weight: 700; color: #1a1a1a; letter-spacing: -0.01em; }
.settings-icon {
width: 32px; height: 32px; border: none; background: transparent;
color: #999; font-size: 15px; cursor: pointer; border-radius: 8px;
display: flex; align-items: center; justify-content: center; transition: all 0.15s;
}
.settings-icon:hover { background: #f5f5f5; color: #333; }
.subtitle { color: #aaa; font-size: 12px; margin-bottom: 16px; }
.status-dot {
display: inline-block; width: 8px; height: 8px; border-radius: 50%;
margin-right: 6px; vertical-align: middle;
}
.status-dot.ready { background: #4caf50; }
.status-dot.loading { background: #ff9800; animation: pulse 1s infinite; }
.status-dot.error { background: #f44336; }
.status-dot.offline { background: #ccc; }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
.section-card {
background: #fafafa; border: 1px solid #f0f0f0; border-radius: 10px;
padding: 14px 16px; margin-bottom: 12px; flex: 1; min-width: 0;
}
.card-pair { display: flex; gap: 8px; margin-bottom: 8px; }
.card-pair .section-card { margin-bottom: 0; }
.section-title {
font-size: 11px; font-weight: 600; color: #999; text-transform: uppercase;
letter-spacing: 0.06em; margin-bottom: 12px; display: flex; align-items: center; gap: 6px;
}
.section-title i { font-size: 11px; }
.input-group { margin-bottom: 10px; position: relative; }
.input-group:last-child { margin-bottom: 0; }
.input-group label { display: block; font-size: 12px; font-weight: 500; margin-bottom: 4px; color: #555; }
.input-group select {
width: 100%; padding: 7px 10px;
border: 1px solid #e0e0e0; border-radius: 6px;
background: #fff; color: #333; font-size: 13px;
outline: none; transition: all 0.15s ease;
}
.input-group select:focus { border-color: #0078d4; box-shadow: 0 0 0 2px rgba(0,120,212,0.06); }
.input-group.no-icon select { padding-left: 10px; }
.path-row { display: flex; gap: 6px; align-items: flex-end; }
.path-row select { width: 90px; flex-shrink: 0; padding: 7px 6px; font-size: 12px; }
.path-row input { flex: 1; }
.slider-group { margin-bottom: 10px; }
.slider-group:last-child { margin-bottom: 0; }
.slider-label {
display: flex; justify-content: space-between; align-items: center;
font-size: 12px; font-weight: 500; margin-bottom: 4px; color: #555;
}
.slider-value { font-weight: 600; color: #0078d4; font-size: 12px; }
input[type="range"] {
width: 100%; height: 4px; -webkit-appearance: none; appearance: none;
background: #e0e0e0; border-radius: 2px; outline: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none; width: 14px; height: 14px;
background: #0078d4; border-radius: 50%; cursor: pointer;
}
.param-desc { font-size: 11px; color: #aaa; margin-top: 2px; line-height: 1.4; }
.btn {
padding: 7px 14px; border: none; border-radius: 6px; font-size: 12px;
font-weight: 600; cursor: pointer; transition: all 0.12s ease;
display: inline-flex; align-items: center; gap: 5px;
}
.btn-primary { background: #0078d4; color: #fff; }
.btn-primary:hover:not(:disabled) { background: #006cbd; }
.btn-primary:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-outline { background: #fff; color: #666; border: 1px solid #e0e0e0; }
.btn-outline:hover { background: #f5f5f5; color: #333; }
.btn-sm { padding: 5px 10px; font-size: 11px; }
.btn-block { width: 100%; justify-content: center; }
.convert-btn {
width: 100%; padding: 10px 18px;
background: linear-gradient(135deg, #0078d4, #005fa3);
color: #fff; border: none; border-radius: 8px;
font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.15s ease;
display: flex; align-items: center; justify-content: center; gap: 8px;
margin-top: 14px; box-shadow: 0 2px 8px rgba(0,120,212,0.2);
}
.convert-btn:hover:not(:disabled) {
background: linear-gradient(135deg, #006cbd, #004e8a);
box-shadow: 0 4px 12px rgba(0,120,212,0.3); transform: translateY(-1px);
}
.convert-btn:active:not(:disabled) { transform: translateY(0); }
.convert-btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
@keyframes spin { to { transform: rotate(360deg); } }
.loading-spinner { animation: spin 1s linear infinite; }
.upload-area {
border: 2px dashed #e0e0e0; border-radius: 8px; padding: 40px;
text-align: center; cursor: pointer; transition: all 0.15s;
background: #fafafa;
}
.upload-area:hover { border-color: #0078d4; background: #f0f7ff; }
.upload-area.has-file { border-color: #4caf50; background: #f0fff0; }
.upload-area input[type="file"] { display: none; }
.upload-area .upload-label { font-size: 13px; color: #888; }
.upload-area .upload-filename { font-size: 13px; color: #333; font-weight: 600; margin-top: 4px; }
/* Right panel */
.right-panel {
flex: 1; display: flex; flex-direction: column; background: #fafafa;
height: 100vh; overflow: hidden;
}
.right-content {
flex: 1; display: flex; align-items: center; justify-content: center;
padding: 20px; min-height: 0; overflow-y: auto;
}
.placeholder { text-align: center; color: #ccc; }
.placeholder i { font-size: 48px; margin-bottom: 12px; }
.placeholder p { font-size: 13px; }
.audio-wrap { width: 100%; max-width: 480px; text-align: center; }
.audio-wrap audio { width: 100%; margin-bottom: 12px; }
.audio-meta { font-size: 12px; color: #888; margin-bottom: 12px; }
.meta-label { font-weight: 600; }
.meta-value { color: #0078d4; }
.audio-actions { display: flex; gap: 8px; justify-content: center; }
.log-area {
flex: 2; display: flex; flex-direction: column;
border-top: 1px solid #e8e8e8; min-height: 0;
}
.log-header {
display: flex; justify-content: space-between; align-items: center;
padding: 8px 16px; background: #2d2d2d; border-bottom: 1px solid #3d3d3d;
}
.log-header span { font-size: 12px; color: #aaa; font-weight: 600; }
.log-content {
flex: 1; overflow-y: auto; background: #1e1e1e; padding: 12px 16px;
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
font-size: 12px; line-height: 1.6; color: #d4d4d4;
}
.log-content::-webkit-scrollbar { width: 6px; }
.log-content::-webkit-scrollbar-thumb { background: #555; border-radius: 3px; }
.log-line { white-space: pre-wrap; word-break: break-all; }
.log-line.error { color: #f48771; }
.log-line.warn { color: #cca700; }
/* Settings modal */
.modal-overlay {
display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.4);
z-index: 100; align-items: center; justify-content: center;
}
.modal-overlay.show { display: flex; }
.modal {
background: #fff; border-radius: 12px; width: 480px; max-height: 80vh;
overflow-y: auto; box-shadow: 0 20px 60px rgba(0,0,0,0.2);
}
.modal-header {
display: flex; justify-content: space-between; align-items: center;
padding: 16px 20px; border-bottom: 1px solid #f0f0f0;
}
.modal-header h3 { font-size: 15px; font-weight: 700; }
.modal-close {
width: 28px; height: 28px; border: none; background: transparent;
color: #999; font-size: 16px; cursor: pointer; border-radius: 6px;
display: flex; align-items: center; justify-content: center;
}
.modal-close:hover { background: #f5f5f5; }
.modal-body { padding: 16px 20px; }
.modal-body .input-group { margin-bottom: 12px; }
.modal-body .input-group label { font-size: 13px; }
.modal-body .input-group input {
width: 100%; padding: 7px 10px; border: 1px solid #e0e0e0; border-radius: 6px;
font-size: 13px; outline: none;
}
.modal-body .input-group input:focus { border-color: #0078d4; }
.engine-status-box {
padding: 10px 12px; border-radius: 8px; margin-bottom: 12px;
font-size: 12px; display: flex; align-items: center; gap: 8px;
}
.engine-status-box.ready { background: #e8f5e9; color: #2e7d32; }
.engine-status-box.loading { background: #fff3e0; color: #e65100; }
.engine-status-box.error { background: #fce4ec; color: #c62828; }
.engine-status-box.offline { background: #f5f5f5; color: #999; }
.modal-footer {
padding: 12px 20px; border-top: 1px solid #f0f0f0;
display: flex; justify-content: flex-end; gap: 8px;
}
/* Toast */
.toast-container { position: fixed; top: 20px; right: 20px; z-index: 200; display: flex; flex-direction: column; gap: 8px; }
.toast {
padding: 10px 16px; border-radius: 8px; font-size: 13px; font-weight: 500;
box-shadow: 0 4px 12px rgba(0,0,0,0.15); animation: slideIn 0.3s ease;
max-width: 360px;
}
.toast.success { background: #e8f5e9; color: #2e7d32; border: 1px solid #c8e6c9; }
.toast.error { background: #fce4ec; color: #c62828; border: 1px solid #f8bbd0; }
.toast.info { background: #e3f2fd; color: #1565c0; border: 1px solid #bbdefb; }
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
@media (max-width: 768px) {
body { flex-direction: column; height: auto; overflow: auto; }
.left-panel { width: 100% !important; max-width: 100% !important; height: auto; border-right: none; border-bottom: 1px solid #e8e8e8; overflow-y: visible; }
.right-panel { height: auto; min-height: 50vh; }
.card-pair { flex-direction: column; }
.modal { width: 92vw !important; max-height: 90vh; overflow-y: auto; left: 4vw !important; transform: none !important; }
.input-section { padding: 14px 16px 20px; }
}
</style>
</head>
<body>
<div class="left-panel">
<div class="input-section">
<!-- Header -->
<div class="header">
<h1><span class="status-dot offline" id="statusDot"></span>RVC 变声</h1>
<button class="settings-icon" onclick="openSettings()"><i class="fas fa-wrench"></i></button>
</div>
<div class="subtitle" id="statusText">引擎未初始化</div>
<!-- Model selection -->
<div class="card-pair">
<div class="section-card">
<div class="section-title"><i class="fas fa-robot"></i> 模型选择</div>
<div class="input-group no-icon">
<label>语音模型 (.pth)</label>
<select id="modelSelect"><option value="">请选择模型</option></select>
</div>
<div class="input-group no-icon" style="margin-top:8px;">
<label>Index 文件</label>
<select id="indexSelect"><option value="">无 (不使用检索)</option></select>
</div>
<div style="margin-top:10px; display:flex; gap:8px;">
<button class="btn btn-primary btn-sm" id="loadModelBtn" style="flex:1;" onclick="switchModel()" disabled><i class="fas fa-refresh"></i> 加载模型</button>
<button class="btn btn-outline btn-sm" style="flex:1;" onclick="saveModelConfig()"><i class="fas fa-check"></i> 保存选择</button>
</div>
</div>
<!-- Upload audio -->
<div class="section-card">
<div class="section-title"><i class="fas fa-file"></i> 输入音频</div>
<div class="upload-area" id="uploadArea" onclick="document.getElementById('audioFile').click()"
ondragover="event.preventDefault(); this.style.borderColor='#0078d4';"
ondragleave="this.style.borderColor='#e0e0e0';"
ondrop="event.preventDefault(); this.style.borderColor='#e0e0e0'; handleDrop(event);">
<input type="file" id="audioFile" accept=".wav,.mp3,.flac,.ogg,.m4a,.aac" onchange="handleFileSelect(this)">
<div class="upload-label" id="uploadLabel"><i class="fas fa-file"></i> 点击选择或拖拽音频文件</div>
<div class="upload-filename" id="uploadFilename" style="display:none;"></div>
</div>
<audio id="uploadPreview" controls style="display:none; width:100%; margin-top:8px; height:32px;"></audio>
</div>
</div>
<!-- Parameters -->
<div class="section-card">
<div class="section-title" style="justify-content:space-between;">
<span><i class="fas fa-wrench"></i> 变声参数</span>
<button class="btn btn-outline btn-sm" onclick="saveInferConfig()" style="text-transform:none; letter-spacing:0; font-size:11px;"><i class="fas fa-check"></i> 保存参数</button>
</div>
<div class="input-group no-icon">
<label>F0 提取方法</label>
<div class="param-desc" style="margin-bottom: 10px">选择音高提取算法,输入歌声可用pm提速,harvest低音好但巨慢无比,crepe效果好但吃GPU,rmvpe效果最好且微吃GPU</div>
<select id="f0Method">
{% for m in f0_methods %}
<option value="{{ m }}">{{ f0_method_names[m] }}</option>
{% endfor %}
</select>
</div>
<div class="input-group no-icon">
<label>后处理重采样至最终采样率</label>
<select id="resampleSr">
<option value="0">不重采样</option>
<option value="16000">16000 Hz</option>
<option value="22050">22050 Hz</option>
<option value="32000">32000 Hz</option>
<option value="40000">40000 Hz</option>
<option value="44100">44100 Hz</option>
<option value="48000">48000 Hz</option>
</select>
</div>
<div class="slider-group">
<div class="slider-label"><span>音调升降</span><span class="slider-value" id="f0UpKeyValue">0</span></div>
<div class="param-desc">整数,半音数量,升八度 +12降八度 -12</div>
<input type="range" id="f0UpKey" min="-24" max="24" value="0" step="1"
oninput="document.getElementById('f0UpKeyValue').textContent=this.value">
</div>
<div class="slider-group">
<div class="slider-label"><span>辅音保护</span><span class="slider-value" id="protectValue">0.33</span></div>
<div class="param-desc">保护清辅音和呼吸声,防止电音撕裂等 artifact。拉满 0.5 不开启,调低加大保护力度但可能降低索引效果</div>
<input type="range" id="protect" min="0" max="0.5" value="0.33" step="0.01"
oninput="document.getElementById('protectValue').textContent=parseFloat(this.value).toFixed(2)">
</div>
<div class="slider-group">
<div class="slider-label"><span>滤波半径</span><span class="slider-value" id="filterRadiusValue">3</span></div>
<div class="param-desc">&ge;3 则对 harvest 音高识别结果使用中值滤波,数值为滤波半径,可以削弱哑音</div>
<input type="range" id="filterRadius" min="0" max="7" value="3" step="1"
oninput="document.getElementById('filterRadiusValue').textContent=this.value">
</div>
<div class="slider-group">
<div class="slider-label"><span>音量包络混合</span><span class="slider-value" id="rmsMixRateValue">0.25</span></div>
<div class="param-desc">输入源音量包络替换输出音量包络的融合比例,越靠近 1 越使用输出包络</div>
<input type="range" id="rmsMixRate" min="0" max="1" value="0.25" step="0.01"
oninput="document.getElementById('rmsMixRateValue').textContent=parseFloat(this.value).toFixed(2)">
</div>
<div class="slider-group">
<div class="slider-label"><span>检索特征比率</span><span class="slider-value" id="indexRateValue">0.75</span></div>
<input type="range" id="indexRate" min="0" max="1" value="0.75" step="0.01"
oninput="document.getElementById('indexRateValue').textContent=parseFloat(this.value).toFixed(2)">
</div>
</div>
<!-- Convert button -->
<button class="convert-btn" id="convertBtn" onclick="convert()" disabled>
<i class="fas fa-play"></i> 开始变声
</button>
</div>
</div>
<!-- Right panel -->
<div class="right-panel">
<div class="right-content">
<div class="placeholder" id="placeholder">
<i class="fas fa-microphone"></i>
<p>上传音频,选择模型,开始变声</p>
</div>
<div id="convertLoading" style="display:none; text-align:center;">
<i class="fas fa-refresh loading-spinner" style="font-size:32px; color:#0078d4;"></i>
<p style="margin-top:10px; color:#888; font-size:13px;">变声处理中...</p>
</div>
<div class="audio-wrap" id="audioWrap" style="display:none;">
<audio id="audioPlayer" controls></audio>
<div class="audio-meta" id="audioMeta"></div>
<div class="audio-actions">
<a id="downloadLink" class="btn btn-outline btn-sm" href="#" download="rvc_output.wav">
<i class="fas fa-file"></i> 下载
</a>
<button class="btn btn-outline btn-sm" onclick="replay()"><i class="fas fa-play"></i> 重播</button>
</div>
</div>
</div>
<div class="log-area">
<div class="log-header">
<span><i class="fas fa-code"></i> 控制台日志</span>
<button class="btn btn-outline btn-sm" onclick="clearLogs()" style="padding:3px 8px; font-size:11px;">清除</button>
</div>
<div class="log-content" id="logContent"></div>
</div>
</div>
<!-- Settings modal -->
<div class="modal-overlay" id="settingsModal">
<div class="modal">
<div class="modal-header">
<h3><i class="fas fa-wrench"></i> RVC 设置</h3>
<button class="modal-close" onclick="closeSettings()">&times;</button>
</div>
<div class="modal-body">
<div class="engine-status-box offline" id="engineStatusBox">
<span class="status-dot offline" id="engineStatusDot"></span>
<span id="engineStatusText">未初始化</span>
</div>
<div class="input-group">
<label>RVC 项目路径</label>
<input type="text" id="rvcProjectPath" placeholder="E:\AI\RVC\RVC1006Nvidia">
</div>
<div class="input-group">
<label>语音模型目录</label>
<div class="path-row">
<select id="modelDirMode" onchange="updatePathPlaceholder('model')">
<option value="relative">相对路径</option>
<option value="absolute">绝对路径</option>
</select>
<input type="text" id="modelDir" placeholder="assets/weights">
</div>
</div>
<div class="input-group">
<label>Index 目录</label>
<div class="path-row">
<select id="indexDirMode" onchange="updatePathPlaceholder('index')">
<option value="relative">相对路径</option>
<option value="absolute">绝对路径</option>
</select>
<input type="text" id="indexDir" placeholder="logs">
</div>
</div>
<div style="display:flex; gap:8px; margin-top:12px;">
<button class="btn btn-primary btn-sm" style="flex:1;" onclick="initEngine()"><i class="fas fa-play"></i> 启动引擎</button>
<button class="btn btn-outline btn-sm" style="flex:1;" onclick="reloadEngine()"><i class="fas fa-refresh"></i> 重启</button>
<button class="btn btn-outline btn-sm" style="flex:1;" onclick="stopEngine()"><i class="fas fa-times-circle"></i> 停止</button>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-outline btn-sm" onclick="closeSettings()">取消</button>
<button class="btn btn-primary btn-sm" onclick="saveSettings()">保存设置</button>
</div>
</div>
</div>
<!-- Toast container -->
<div class="toast-container" id="toastContainer"></div>
<script>
// === State ===
var _uploadedPath = '';
var _uploadedFilename = '';
var _logPollTimer = null;
// === Elements ===
var statusDot = document.getElementById('statusDot');
var statusText = document.getElementById('statusText');
var convertBtn = document.getElementById('convertBtn');
var placeholder = document.getElementById('placeholder');
var convertLoading = document.getElementById('convertLoading');
var audioWrap = document.getElementById('audioWrap');
var audioPlayer = document.getElementById('audioPlayer');
var downloadLink = document.getElementById('downloadLink');
var audioMeta = document.getElementById('audioMeta');
var logContent = document.getElementById('logContent');
// === Toast ===
function showMessage(text, type, duration) {
type = type || 'info'; duration = duration || 3000;
var t = document.createElement('div');
t.className = 'toast ' + type;
t.textContent = text;
document.getElementById('toastContainer').appendChild(t);
setTimeout(function() { t.style.opacity = '0'; t.style.transition = 'opacity 0.3s'; setTimeout(function(){ t.remove(); }, 300); }, duration);
}
// === Settings ===
function openSettings() { document.getElementById('settingsModal').classList.add('show'); checkEngineStatus(); }
function closeSettings() { document.getElementById('settingsModal').classList.remove('show'); }
function saveSettings() {
var cfg = {
rvc_project_path: document.getElementById('rvcProjectPath').value.trim(),
model_dir: document.getElementById('modelDir').value.trim(),
model_dir_mode: document.getElementById('modelDirMode').value,
index_dir: document.getElementById('indexDir').value.trim(),
index_dir_mode: document.getElementById('indexDirMode').value
};
fetch('/rvc/config', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify(cfg)
}).then(function(r){ return r.json(); }).then(function(data){
if (data.success) { showMessage('设置已保存', 'success'); loadMainConfig(); }
else showMessage(data.error || '保存失败', 'error');
});
}
function saveInferConfig() {
var cfg = {
f0_up_key: parseInt(document.getElementById('f0UpKey').value) || 0,
f0_method: document.getElementById('f0Method').value,
index_rate: parseFloat(document.getElementById('indexRate').value),
filter_radius: parseInt(document.getElementById('filterRadius').value),
resample_sr: parseInt(document.getElementById('resampleSr').value) || 0,
rms_mix_rate: parseFloat(document.getElementById('rmsMixRate').value),
protect: parseFloat(document.getElementById('protect').value)
};
fetch('/rvc/config', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify(cfg)
}).then(function(r){ return r.json(); }).then(function(data){
if (data.success) showMessage('参数已保存', 'success');
else showMessage(data.error || '保存失败', 'error');
});
}
function saveModelConfig() {
var cfg = {
last_model: document.getElementById('modelSelect').value || '',
last_index: document.getElementById('indexSelect').value || ''
};
fetch('/rvc/config', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify(cfg)
}).then(function(r){ return r.json(); }).then(function(data){
if (data.success) showMessage('模型选择已保存', 'success');
else showMessage(data.error || '保存失败', 'error');
});
}
// === Engine ===
function initEngine() {
showMessage('正在启动引擎...', 'info');
fetch('/rvc/init-engine', {method:'POST'}).then(function(r){ return r.json(); }).then(function(data){
if (data.success) showMessage('引擎启动成功 (' + (data.device||'') + ')', 'success');
else showMessage(data.error || '启动失败', 'error');
startLogPolling();
});
}
function reloadEngine() {
showMessage('正在重启引擎...', 'info');
fetch('/rvc/reload-engine', {method:'POST'}).then(function(r){ return r.json(); }).then(function(data){
if (data.success) showMessage('引擎重启成功', 'success');
else showMessage(data.error || '重启失败', 'error');
startLogPolling();
});
}
function stopEngine() {
fetch('/rvc/stop-engine', {method:'POST'}).then(function(r){ return r.json(); }).then(function(data){
showMessage('引擎已停止', 'info');
stopLogPolling();
updateEngineUI({initialized:false, loading:false, error:null});
});
}
function checkEngineStatus() {
fetch('/rvc/engine-status').then(function(r){ return r.json(); }).then(function(data){
updateEngineUI(data);
updateLogs(data.logs || []);
});
}
function updateEngineUI(data) {
var dot = statusDot;
var txt = statusText;
var eDot = document.getElementById('engineStatusDot');
var eTxt = document.getElementById('engineStatusText');
var eBox = document.getElementById('engineStatusBox');
dot.className = 'status-dot';
eDot.className = 'status-dot';
eBox.className = 'engine-status-box';
if (data.loading) {
dot.classList.add('loading'); eDot.classList.add('loading');
eBox.classList.add('loading');
txt.textContent = '引擎加载中...';
eTxt.textContent = '加载中...';
document.getElementById('loadModelBtn').disabled = true;
convertBtn.disabled = true;
} else if (data.initialized) {
dot.classList.add('ready'); eDot.classList.add('ready');
eBox.classList.add('ready');
document.getElementById('loadModelBtn').disabled = false;
if (data.model_loaded) {
txt.textContent = '引擎就绪 (' + (data.device || '') + ')';
eTxt.textContent = '就绪 (' + (data.device || '') + ')';
convertBtn.disabled = !_uploadedPath;
} else {
txt.textContent = '引擎就绪,请加载模型';
eTxt.textContent = '就绪,请加载模型';
convertBtn.disabled = true;
}
} else if (data.error) {
dot.classList.add('error'); eDot.classList.add('error');
eBox.classList.add('error');
txt.textContent = '引擎错误';
eTxt.textContent = data.error;
document.getElementById('loadModelBtn').disabled = true;
convertBtn.disabled = true;
} else {
dot.classList.add('offline'); eDot.classList.add('offline');
eBox.classList.add('offline');
txt.textContent = '引擎未初始化';
eTxt.textContent = '未初始化';
document.getElementById('loadModelBtn').disabled = true;
convertBtn.disabled = true;
}
}
function updateLogs(logs) {
if (!logs.length) {
if (!document.querySelector('#statusDot.loading')) logContent.innerHTML = '';
return;
}
var html = '';
logs.forEach(function(line) {
var cls = 'log-line';
if (/error|exception|traceback/i.test(line)) cls += ' error';
else if (/warn/i.test(line)) cls += ' warn';
html += '<div class="' + cls + '">' + escapeHtml(line) + '</div>';
});
logContent.innerHTML = html;
logContent.scrollTop = logContent.scrollHeight;
}
function escapeHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function startLogPolling() {
stopLogPolling();
_logPollTimer = setInterval(checkEngineStatus, 600);
}
function stopLogPolling() {
if (_logPollTimer) { clearInterval(_logPollTimer); _logPollTimer = null; }
}
function clearLogs() {
logContent.innerHTML = '';
fetch('/rvc/clear-logs', {method:'POST'});
}
function updatePathPlaceholder(type) {
var mode = document.getElementById(type + 'DirMode').value;
var input = document.getElementById(type + 'Dir');
if (type === 'model') {
input.placeholder = mode === 'absolute' ? 'E:\\models\\weights' : 'assets/weights';
} else {
input.placeholder = mode === 'absolute' ? 'E:\\models\\logs' : 'logs';
}
}
// === Model ===
function switchModel() {
var model = document.getElementById('modelSelect').value;
if (!model) { showMessage('请选择模型', 'error'); return; }
showMessage('正在加载模型...', 'info');
fetch('/rvc/switch-model', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({model_name: model})
}).then(function(r){ return r.json(); }).then(function(data){
if (data.success) showMessage(data.message || '模型已加载', 'success');
else showMessage(data.error || '加载失败', 'error');
});
}
// === File upload ===
function handleFileSelect(input) {
if (!input.files.length) return;
uploadFile(input.files[0]);
}
function handleDrop(event) {
var files = event.dataTransfer.files;
if (files.length) uploadFile(files[0]);
}
function uploadFile(file) {
var formData = new FormData();
formData.append('audio', file);
showMessage('正在上传...', 'info');
fetch('/rvc/upload-audio', {method:'POST', body: formData}).then(function(r){ return r.json(); }).then(function(data){
if (data.success) {
_uploadedPath = data.path;
_uploadedFilename = data.filename;
document.getElementById('uploadLabel').style.display = 'none';
var fn = document.getElementById('uploadFilename');
fn.textContent = data.filename;
fn.style.display = 'block';
document.getElementById('uploadArea').classList.add('has-file');
var preview = document.getElementById('uploadPreview');
preview.src = URL.createObjectURL(file);
preview.style.display = '';
convertBtn.disabled = false;
showMessage('上传成功', 'success');
} else {
showMessage(data.error || '上传失败', 'error');
}
});
}
// === Convert ===
function convert() {
if (!_uploadedPath) { showMessage('请先上传音频', 'error'); return; }
convertBtn.disabled = true;
placeholder.style.display = 'none';
audioWrap.style.display = 'none';
convertLoading.style.display = '';
var payload = {
input_path: _uploadedPath,
file_index: document.getElementById('indexSelect').value || '',
f0_up_key: parseInt(document.getElementById('f0UpKey').value) || 0,
f0_method: document.getElementById('f0Method').value,
index_rate: parseFloat(document.getElementById('indexRate').value),
filter_radius: parseInt(document.getElementById('filterRadius').value),
resample_sr: parseInt(document.getElementById('resampleSr').value) || 0,
rms_mix_rate: parseFloat(document.getElementById('rmsMixRate').value),
protect: parseFloat(document.getElementById('protect').value)
};
var startTime = Date.now();
fetch('/rvc/convert', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify(payload)
}).then(function(r){ return r.json(); }).then(function(data){
if (!data.success) {
showMessage(data.error || '变声失败', 'error');
convertLoading.style.display = 'none';
placeholder.style.display = '';
convertBtn.disabled = false;
return;
}
pollTask(data.task_id, startTime);
}).catch(function(e) {
showMessage('请求失败: ' + e.message, 'error');
convertLoading.style.display = 'none';
placeholder.style.display = '';
convertBtn.disabled = false;
});
}
function pollTask(taskId, startTime) {
var poll = setInterval(function() {
fetch('/rvc/status/' + taskId).then(function(r){ return r.json(); }).then(function(data){
if (!data.success) return;
var p = data.progress;
if (p.status === 'done') {
clearInterval(poll);
var elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
convertLoading.style.display = 'none';
audioWrap.style.display = '';
audioPlayer.src = data.audio_url;
var safeName = _uploadedFilename.replace(/\.[^.]+$/, '').replace(/[\\/:*?"<>|]/g, '_');
var model = document.getElementById('modelSelect').value.replace(/\.[^.]+$/, '');
var now = new Date();
var ts = now.getFullYear() + String(now.getMonth()+1).padStart(2,'0') + String(now.getDate()).padStart(2,'0')
+ String(now.getHours()).padStart(2,'0') + String(now.getMinutes()).padStart(2,'0')
+ String(now.getSeconds()).padStart(2,'0') + String(now.getMilliseconds()).padStart(3,'0');
var dlName = model + '_' + safeName + '_' + ts + '.wav';
fetch(data.audio_url).then(function(r){ return r.blob(); }).then(function(blob){
if (downloadLink._blobUrl) URL.revokeObjectURL(downloadLink._blobUrl);
downloadLink._blobUrl = URL.createObjectURL(blob);
downloadLink.href = downloadLink._blobUrl;
downloadLink.download = dlName;
});
audioMeta.innerHTML = '<span class="meta-label">耗时</span> <span class="meta-value">' + elapsed + 's</span>';
convertBtn.disabled = false;
showMessage('变声完成,耗时 ' + elapsed + ' 秒', 'success');
} else if (p.status === 'error') {
clearInterval(poll);
showMessage(p.error || '变声失败', 'error');
convertLoading.style.display = 'none';
placeholder.style.display = '';
convertBtn.disabled = false;
}
});
}, 500);
}
function replay() { audioPlayer.currentTime = 0; audioPlayer.play(); }
// === Load config ===
function loadMainConfig() {
fetch('/rvc/config').then(function(r){ return r.json(); }).then(function(cfg){
// Populate models
var mSel = document.getElementById('modelSelect');
mSel.innerHTML = '<option value="">请选择模型</option>';
(cfg.models || []).forEach(function(m) {
var opt = document.createElement('option');
opt.value = m; opt.textContent = m;
if (m === cfg.last_model) opt.selected = true;
mSel.appendChild(opt);
});
// Populate indexes
var iSel = document.getElementById('indexSelect');
iSel.innerHTML = '<option value="">无 (不使用检索)</option>';
(cfg.index_files || []).forEach(function(f) {
var opt = document.createElement('option');
opt.value = f; opt.textContent = f.replace(/\\/g, '/').split('/').pop();
if (f === cfg.last_index) opt.selected = true;
iSel.appendChild(opt);
});
// Populate params
document.getElementById('f0UpKey').value = cfg.f0_up_key || 0;
document.getElementById('f0UpKeyValue').textContent = cfg.f0_up_key || 0;
document.getElementById('f0Method').value = cfg.f0_method || 'rmvpe';
document.getElementById('indexRate').value = cfg.index_rate != null ? cfg.index_rate : 0.75;
document.getElementById('indexRateValue').textContent = (cfg.index_rate != null ? cfg.index_rate : 0.75).toFixed(2);
document.getElementById('filterRadius').value = cfg.filter_radius != null ? cfg.filter_radius : 3;
document.getElementById('filterRadiusValue').textContent = cfg.filter_radius != null ? cfg.filter_radius : 3;
document.getElementById('resampleSr').value = cfg.resample_sr || 0;
document.getElementById('rmsMixRate').value = cfg.rms_mix_rate != null ? cfg.rms_mix_rate : 0.25;
document.getElementById('rmsMixRateValue').textContent = (cfg.rms_mix_rate != null ? cfg.rms_mix_rate : 0.25).toFixed(2);
document.getElementById('protect').value = cfg.protect != null ? cfg.protect : 0.33;
document.getElementById('protectValue').textContent = (cfg.protect != null ? cfg.protect : 0.33).toFixed(2);
// Populate settings modal
document.getElementById('rvcProjectPath').value = cfg.rvc_project_path || '';
document.getElementById('modelDir').value = cfg.model_dir || 'assets/weights';
document.getElementById('modelDirMode').value = cfg.model_dir_mode || 'relative';
document.getElementById('indexDir').value = cfg.index_dir || 'logs';
document.getElementById('indexDirMode').value = cfg.index_dir_mode || 'relative';
// Engine status
if (cfg.engine_status) {
updateEngineUI(cfg.engine_status);
if (cfg.engine_status.initialized || cfg.engine_status.loading) startLogPolling();
}
});
}
// === Keyboard ===
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeSettings();
});
// === Init ===
loadMainConfig();
</script>
</body>
</html>