generated from dellevin/template
feat: complete local version to overwrite remote
This commit is contained in:
816
flask-dev-api/templates/rvc.html
Normal file
816
flask-dev-api/templates/rvc.html
Normal file
@@ -0,0 +1,816 @@
|
||||
<!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; } }
|
||||
</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">≥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()">×</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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
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>
|
||||
Reference in New Issue
Block a user