Files
python_script/flask-dev-api/templates/audio_slicer.html
DelLevin-Home d94a9bad1b 555
2026-06-17 21:05:01 +08:00

1608 lines
71 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>Audio Slicer 音频分割</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: 460px; 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; margin-bottom: 2px; }
.header h1 { font-size: 18px; font-weight: 700; color: #1a1a1a; letter-spacing: -0.01em; }
.subtitle { color: #aaa; font-size: 12px; margin-bottom: 16px; }
.section-card {
background: #fafafa; border: 1px solid #f0f0f0;
border-radius: 10px; padding: 14px 16px; margin-bottom: 12px;
}
.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; }
/* 上传区 */
.upload-area {
border: 2px dashed #e0e0e0; border-radius: 10px;
padding: 28px 16px; text-align: center; cursor: pointer;
transition: all 0.2s; background: #fff;
}
.upload-area:hover, .upload-area.dragover {
border-color: #0078d4; background: #f0f7ff;
}
.upload-area i { font-size: 28px; color: #ccc; margin-bottom: 8px; display: block; }
.upload-area .label { font-size: 13px; color: #999; }
.upload-area .filename { font-size: 13px; color: #0078d4; font-weight: 600; margin-top: 4px; }
.upload-area input[type="file"] { display: none; }
/* 音频信息 */
.audio-info {
display: grid; grid-template-columns: 1fr 1fr; gap: 8px;
margin-top: 8px;
}
.info-item {
background: #fff; border: 1px solid #f0f0f0; border-radius: 6px;
padding: 8px 10px; font-size: 12px;
}
.info-item .info-label { color: #999; font-size: 11px; }
.info-item .info-value { font-weight: 600; color: #333; margin-top: 2px; }
/* 参数 */
.param-row {
display: flex; align-items: center; gap: 10px; margin-bottom: 10px;
}
.param-row:last-child { margin-bottom: 0; }
.param-label {
font-size: 12px; color: #666; width: 100px; flex-shrink: 0;
}
.param-row input[type="range"] { flex: 1; accent-color: #0078d4; }
.param-val {
width: 60px; text-align: center; padding: 4px 6px;
border: 1px solid #e0e0e0; border-radius: 4px;
font-size: 12px; font-weight: 600; color: #333;
}
.param-unit { font-size: 11px; color: #999; width: 24px; }
/* 按钮 */
.action-btn {
width: 100%; padding: 10px 18px;
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;
box-shadow: 0 2px 8px rgba(0,120,212,0.2);
}
.action-btn:hover { transform: translateY(-1px); }
.action-btn:active { transform: translateY(0); }
.action-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
.action-btn.analyze {
background: linear-gradient(135deg, #0078d4, #005fa3); color: #fff;
}
.action-btn.slice {
background: linear-gradient(135deg, #0a7a1a, #065f12); color: #fff;
}
.btn-row { display: flex; gap: 8px; margin-top: 12px; }
.btn-row .action-btn { flex: 1; }
/* 右侧面板 */
.right-panel {
flex: 1; background: #fff;
display: flex; flex-direction: column;
height: 100vh;
}
.right-header {
padding: 20px 24px 16px;
display: flex; align-items: center; justify-content: space-between;
border-bottom: 1px solid #e8e8e8;
}
.right-header h2 { font-size: 15px; font-weight: 600; color: #333; }
.right-body {
flex: 1; overflow-y: auto; padding: 16px 24px 24px;
}
/* 预览表格 */
.preview-table {
width: 100%; border-collapse: collapse; font-size: 13px;
}
.preview-table th {
text-align: left; padding: 8px 12px; color: #999;
font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em;
font-size: 11px; border-bottom: 2px solid #e0e0e0;
}
.preview-table td {
padding: 8px 12px; border-bottom: 1px solid #f0f0f0; color: #555;
}
.preview-table tr:hover td { background: #fafafa; }
/* 切片行 */
.slice-row { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-bottom: 1px solid #f0f0f0; }
.slice-row:last-child { border-bottom: none; }
.slice-idx { width: 36px; font-size: 13px; font-weight: 700; color: #999; text-align: center; }
.slice-name { flex: 1; font-size: 13px; color: #333; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.slice-dur { font-size: 12px; color: #999; width: 60px; text-align: right; }
.slice-actions { display: flex; gap: 6px; }
.slice-btn {
width: 30px; height: 30px; border: 1px solid #e0e0e0; border-radius: 6px;
background: #fff; color: #666; cursor: pointer; display: flex;
align-items: center; justify-content: center; font-size: 12px;
transition: all 0.15s;
}
.slice-btn:hover { background: #f0f0f0; color: #333; border-color: #ccc; }
.slice-btn.play-btn:hover { color: #0078d4; border-color: #0078d4; }
.param-save-btn {
margin-left: auto; padding: 3px 10px; border: 1px solid #e0e0e0;
border-radius: 5px; background: #fff; color: #666; font-size: 11px;
font-weight: 600; cursor: pointer; transition: all 0.15s;
display: inline-flex; align-items: center; gap: 4px;
}
.param-save-btn:hover { background: #f0f0f0; color: #333; border-color: #ccc; }
/* 进度条 */
.progress-bar {
width: 100%; height: 6px; background: #f0f0f0; border-radius: 3px;
overflow: hidden; margin: 12px 0;
}
.progress-fill {
height: 100%; background: linear-gradient(90deg, #0078d4, #0a7a1a);
border-radius: 3px; transition: width 0.3s;
}
/* 空状态 */
.empty-state {
display: flex; flex-direction: column; align-items: center;
justify-content: center; height: 100%; color: #ccc;
}
.empty-state i { font-size: 48px; margin-bottom: 16px; }
.empty-state p { font-size: 14px; }
/* 顶部操作栏 */
.top-actions {
display: flex; gap: 8px; margin-bottom: 16px;
}
.top-btn {
padding: 7px 14px; border: 1px solid #e0e0e0; border-radius: 6px;
background: #fff; color: #666; font-size: 12px; font-weight: 600;
cursor: pointer; display: flex; align-items: center; gap: 5px;
transition: all 0.15s;
}
.top-btn:hover { background: #f0f0f0; color: #333; border-color: #ccc; }
.top-btn.primary { background: #0078d4; color: #fff; border-color: #0078d4; }
.top-btn.primary:hover { background: #006cbd; }
.status-text { font-size: 12px; color: #999; margin: 8px 0; }
/* 片段选择 */
.seg-row {
display: flex; align-items: center; gap: 8px; padding: 6px 8px;
border-bottom: 1px solid #f0f0f0; cursor: pointer; border-radius: 4px;
transition: background 0.15s;
}
.seg-row:hover { background: #f5f5f5; }
.seg-row.selected { background: #e8f0fe; }
.seg-row.dimmed { opacity: 0.4; }
.seg-check {
width: 16px; height: 16px; accent-color: #0078d4; cursor: pointer;
flex-shrink: 0;
}
.seg-ops { display: flex; gap: 4px; margin-bottom: 8px; }
.seg-op-btn {
padding: 3px 10px; border: 1px solid #e0e0e0; border-radius: 4px;
background: #fff; color: #666; font-size: 11px; cursor: pointer;
transition: all 0.15s;
}
.seg-op-btn:hover { background: #f0f0f0; border-color: #ccc; }
/* 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; } }
/* 模式切换 */
.mode-toggle {
display: flex; gap: 0; margin-bottom: 12px;
border: 1px solid #e0e0e0; border-radius: 6px; overflow: hidden;
}
.mode-btn {
flex: 1; padding: 7px 0; text-align: center;
font-size: 12px; font-weight: 600; cursor: pointer;
background: #fff; color: #999; border: none;
transition: all 0.15s;
}
.mode-btn.active { background: #0078d4; color: #fff; }
.mode-btn:first-child { border-right: 1px solid #e0e0e0; }
.mode-btn.active:first-child { border-right-color: #0078d4; }
/* 切割点列表 */
.cut-list { margin-top: 8px; }
.cut-row {
display: flex; align-items: center; gap: 6px;
padding: 5px 0; border-bottom: 1px solid #f0f0f0;
}
.cut-row:last-child { border-bottom: none; }
.cut-idx { width: 24px; font-size: 12px; font-weight: 700; color: #999; text-align: center; }
.cut-input {
flex: 1; padding: 4px 8px; border: 1px solid #e0e0e0; border-radius: 4px;
font-size: 12px; text-align: center; outline: none;
transition: border-color 0.15s;
}
.cut-input:focus { border-color: #0078d4; }
.cut-del {
width: 24px; height: 24px; border: none; background: none;
color: #ccc; cursor: pointer; font-size: 12px; border-radius: 4px;
display: flex; align-items: center; justify-content: center;
transition: all 0.15s;
}
.cut-del:hover { background: #fce4ec; color: #c62828; }
.cut-add {
display: flex; align-items: center; justify-content: center; gap: 4px;
width: 100%; padding: 6px; border: 1px dashed #e0e0e0; border-radius: 6px;
background: none; color: #999; font-size: 12px; cursor: pointer;
margin-top: 6px; transition: all 0.15s;
}
.cut-add:hover { border-color: #0078d4; color: #0078d4; }
.cut-hint { font-size: 11px; color: #aaa; margin-top: 8px; line-height: 1.5; }
/* 时间轴 (HTML div 实现) */
.timeline-wrap { position: relative; margin-bottom: 28px; margin-top: 22px; padding-bottom: 22px; }
#timelineBar {
position: relative; height: 100px;
border-radius: 8px; border: 1px solid #e0e0e0;
cursor: crosshair; user-select: none; overflow: visible;
background: #f0f4f8;
}
#timelineBar.readonly { cursor: default; }
#timelineBar.readonly .cut-marker { cursor: default; }
#waveformCanvas {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
border-radius: 8px; pointer-events: none;
}
#segmentOverlay {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
border-radius: 8px; pointer-events: none; z-index: 1;
}
.seg-gray {
position: absolute; top: 0; height: 100%;
background: rgba(0,0,0,0.18); pointer-events: none;
}
#playCursor {
position: absolute; top: 0; height: 100%; width: 10px;
transform: translateX(-5px); z-index: 5;
pointer-events: auto; cursor: col-resize;
display: none;
}
#playCursor::after {
content: ''; position: absolute; left: 50%; top: 0; bottom: 0;
width: 2px; transform: translateX(-50%);
background: #e53935; box-shadow: 0 0 4px rgba(229,57,53,0.4);
pointer-events: none;
}
#cursorHandle {
position: absolute; left: 50%; top: -6px; transform: translateX(-50%);
width: 12px; height: 12px; border-radius: 50%;
background: #e53935; border: 2px solid #fff;
box-shadow: 0 1px 4px rgba(0,0,0,0.25);
pointer-events: none;
}
.player-controls {
display: flex; align-items: center; gap: 10px;
padding: 6px 12px; margin-top: 4px;
background: #fff; border: 1px solid #e0e0e0; border-radius: 8px;
font-size: 12px; color: #555;
}
.player-controls button {
width: 30px; height: 30px; border: none; border-radius: 50%;
background: #0078d4; color: #fff; cursor: pointer;
display: flex; align-items: center; justify-content: center;
font-size: 12px; transition: background 0.15s;
}
.player-controls button:hover { background: #005fa3; }
.player-controls .p-time { font-variant-numeric: tabular-nums; min-width: 36px; text-align: center; }
.player-controls input[type="range"] {
flex: 1; height: 4px; -webkit-appearance: none; appearance: none;
background: #e0e0e0; border-radius: 2px; outline: none; cursor: pointer;
}
.player-controls input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none;
width: 14px; height: 14px; border-radius: 50%;
background: #0078d4; cursor: pointer;
}
.cut-marker {
position: absolute; top: 0; width: 24px; height: 100%;
transform: translateX(-50%); z-index: 2; cursor: grab;
}
.cut-marker:active { cursor: grabbing; }
.cut-marker-line {
position: absolute; left: 50%; top: 0; bottom: 0;
width: 2px; background: #e53935; transform: translateX(-50%);
}
.cut-marker-tri {
position: absolute; left: 50%; top: -1px;
transform: translateX(-50%);
width: 0; height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 8px solid #e53935;
pointer-events: none;
}
.cut-marker-label {
position: absolute; left: 50%; bottom: -20px;
transform: translateX(-50%);
font-size: 10px; color: #e53935; font-weight: 600;
white-space: nowrap; pointer-events: none;
}
@media (max-width: 768px) {
body { flex-direction: column; height: auto; overflow: auto; }
.left-panel { width: 100% !important; height: auto; border-right: none; border-bottom: 1px solid #e8e8e8; overflow-y: visible; }
.right-panel { height: auto; min-height: 50vh; }
.input-section { padding: 14px 16px 20px; }
}
</style>
</head>
<body>
<div class="toast-container" id="toastContainer"></div>
<div class="left-panel">
<div class="input-section">
<div class="header">
<h1><i class="fas fa-scissors" style="color:#0078d4;font-size:16px;margin-right:6px;"></i>Audio Slicer</h1>
</div>
<p class="subtitle">基于静音检测的智能音频分割</p>
<!-- 上传 -->
<div class="section-card">
<div class="section-title"><i class="fas fa-file"></i> 上传音频</div>
<div class="upload-area" id="uploadArea" onclick="document.getElementById('audioInput').click()">
<i class="fas fa-folder-open"></i>
<div class="label" id="uploadLabel">点击或拖拽音频文件到此处</div>
<div class="filename" id="uploadFilename" style="display:none;"></div>
<input type="file" id="audioInput" accept=".wav,.flac,.ogg,.mp3,.aac,.m4a,.wma,.aiff,.opus">
</div>
<div class="audio-info" id="audioInfo" style="display:none;">
<div class="info-item">
<div class="info-label">采样率</div>
<div class="info-value" id="infoSr">-</div>
</div>
<div class="info-item">
<div class="info-label">声道数</div>
<div class="info-value" id="infoCh">-</div>
</div>
<div class="info-item">
<div class="info-label">时长</div>
<div class="info-value" id="infoDur">-</div>
</div>
<div class="info-item">
<div class="info-label">文件名</div>
<div class="info-value" id="infoName" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">-</div>
</div>
</div>
</div>
<!-- 模式切换 -->
<div class="mode-toggle">
<div class="mode-btn active" id="modeAuto" onclick="setCutMode('auto')">自动切割</div>
<div class="mode-btn" id="modeManual" onclick="setCutMode('manual')">手动切割</div>
</div>
<!-- 自动模式:参数 -->
<div class="section-card" id="autoParamsCard">
<div class="section-title">
<i class="fas fa-wrench"></i> 分割参数
<button class="param-save-btn" onclick="saveConfig()"><i class="fas fa-check"></i> 保存</button>
</div>
<div class="param-row">
<span class="param-label">静音阈值</span>
<input type="range" id="pThreshold" min="-80" max="-10" value="{{ config.threshold }}" oninput="syncParam(this, 'vThreshold')">
<input type="number" class="param-val" id="vThreshold" value="{{ config.threshold }}" onchange="syncSlider(this, 'pThreshold')">
<span class="param-unit">dB</span>
</div>
<div class="param-row">
<span class="param-label">最短片段</span>
<input type="range" id="pMinLength" min="1000" max="30000" step="500" value="{{ config.min_length }}" oninput="syncParam(this, 'vMinLength')">
<input type="number" class="param-val" id="vMinLength" value="{{ config.min_length }}" onchange="syncSlider(this, 'pMinLength')">
<span class="param-unit">ms</span>
</div>
<div class="param-row">
<span class="param-label">最短静音</span>
<input type="range" id="pMinInterval" min="50" max="3000" step="50" value="{{ config.min_interval }}" oninput="syncParam(this, 'vMinInterval')">
<input type="number" class="param-val" id="vMinInterval" value="{{ config.min_interval }}" onchange="syncSlider(this, 'pMinInterval')">
<span class="param-unit">ms</span>
</div>
<div class="param-row">
<span class="param-label">帧长</span>
<input type="range" id="pHopSize" min="5" max="100" step="5" value="{{ config.hop_size }}" oninput="syncParam(this, 'vHopSize')">
<input type="number" class="param-val" id="vHopSize" value="{{ config.hop_size }}" onchange="syncSlider(this, 'pHopSize')">
<span class="param-unit">ms</span>
</div>
<div class="param-row">
<span class="param-label">保留静音</span>
<input type="range" id="pMaxSilKept" min="100" max="10000" step="100" value="{{ config.max_sil_kept }}" oninput="syncParam(this, 'vMaxSilKept')">
<input type="number" class="param-val" id="vMaxSilKept" value="{{ config.max_sil_kept }}" onchange="syncSlider(this, 'pMaxSilKept')">
<span class="param-unit">ms</span>
</div>
</div>
<!-- 手动模式:切割点 -->
<div class="section-card" id="manualCutCard" style="display:none;">
<div class="section-title">
<i class="fas fa-scissors"></i> 切割点
</div>
<div class="cut-list" id="cutList"></div>
<button class="cut-add" onclick="addCutPointRow()"><i class="fas fa-plus"></i> 添加切割点</button>
<div class="cut-hint">点击时间轴添加标记,拖动标记调整位置,点击已有标记删除</div>
</div>
<!-- 操作按钮 -->
<div class="btn-row">
<button class="action-btn analyze" id="btnAnalyze" onclick="doAnalyze()" disabled>
<i class="fas fa-search"></i> 分析
</button>
<button class="action-btn slice" id="btnSlice" onclick="doSlice()" disabled>
<i class="fas fa-scissors"></i> 开始切割
</button>
<button class="action-btn slice" id="btnManualSlice" onclick="doManualSlice()" style="display:none;" disabled>
<i class="fas fa-scissors"></i> 开始切割
</button>
</div>
</div>
</div>
<div class="right-panel">
<div class="right-header">
<h2 id="rightTitle"><i class="fas fa-check-circle" style="color:#0078d4;margin-right:6px;"></i>切片结果</h2>
</div>
<div class="right-body" id="rightBody">
<div class="empty-state" id="emptyState">
<i class="fas fa-scissors"></i>
<p>上传音频文件后开始分析</p>
</div>
<div id="timelineArea" style="display:none;">
<div class="timeline-wrap" id="timelineBar"></div>
<div class="player-controls" id="playerControls" style="display:none;">
<button onclick="playerToggle()" title="播放/暂停"><i id="playerIcon" class="fas fa-play"></i></button>
<span class="p-time" id="playerTime">0:00</span>
<input type="range" id="playerSeekBar" min="0" max="1000" value="0" step="1">
<span class="p-time" id="playerTotal">0:00</span>
</div>
<div id="redoWrap" style="display:none;text-align:center;margin-top:8px;">
<button class="action-btn slice" style="width:auto;padding:8px 20px;font-size:13px;" onclick="redoManualSlice()">
<i class="fas fa-redo"></i> 切割点已更改,点击重新切割
</button>
</div>
</div>
<div id="resultArea" style="display:none;margin-top: 20px">
<div class="top-actions" id="topActions" style="display:none;">
<button class="top-btn primary" onclick="downloadAll()">
<i class="fas fa-folder-plus"></i> 打包下载全部
</button>
</div>
<div class="status-text" id="statusText"></div>
<div class="progress-bar" id="progressWrap" style="display:none;">
<div class="progress-fill" id="progressFill" style="width:0%;"></div>
</div>
<div id="sliceList"></div>
</div>
</div>
</div>
<script>
let taskId = null;
let pollTimer = null;
// 手动切割状态
let cutMode = 'auto';
let cutPoints = [];
let audioDuration = 0;
let cutPointsChanged = false;
let selectedSegments = []; // 选中的片段索引
let manualSegSelected = []; // 手动模式:左侧面板勾选的片段
let manualSegChanged = false; // 用户是否手动调整过勾选
let manualSegInit = false; // 新建预览时重置勾选
// 播放器状态
var playerAudio = null;
var playerPlaying = false;
var _playerRAF = null;
var _playerStartSec = 0;
var _playerEndSec = 0;
var _playerMode = 'full'; // 'full' 全曲 | 'seg' 片段
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);
}
// 参数同步
function syncParam(slider, valId) {
document.getElementById(valId).value = slider.value;
}
function syncSlider(input, sliderId) {
document.getElementById(sliderId).value = input.value;
}
function getSettings() {
return {
threshold: parseInt(document.getElementById('vThreshold').value),
min_length: parseInt(document.getElementById('vMinLength').value),
min_interval: parseInt(document.getElementById('vMinInterval').value),
hop_size: parseInt(document.getElementById('vHopSize').value),
max_sil_kept: parseInt(document.getElementById('vMaxSilKept').value),
};
}
async function saveConfig() {
try {
var res = await fetch('/audio-slicer/config', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify(getSettings())
});
var data = await res.json();
if (data.success) showMessage('参数已保存', 'success');
} catch (e) {
showMessage('保存失败: ' + e.message, 'error');
}
}
function formatDur(sec) {
if (sec < 60) return sec.toFixed(1) + 's';
var m = Math.floor(sec / 60);
var s = (sec % 60).toFixed(1);
return m + 'm ' + s + 's';
}
// 上传区拖拽
var uploadArea = document.getElementById('uploadArea');
uploadArea.addEventListener('dragover', function(e) { e.preventDefault(); this.classList.add('dragover'); });
uploadArea.addEventListener('dragleave', function() { this.classList.remove('dragover'); });
uploadArea.addEventListener('drop', function(e) {
e.preventDefault(); this.classList.remove('dragover');
var files = e.dataTransfer.files;
if (files.length > 0) uploadFile(files[0]);
});
document.getElementById('audioInput').addEventListener('change', function() {
if (this.files.length > 0) uploadFile(this.files[0]);
});
async function uploadFile(file) {
var fd = new FormData();
fd.append('audio', file);
document.getElementById('uploadLabel').textContent = '上传中...';
document.getElementById('uploadFilename').style.display = 'none';
document.getElementById('btnAnalyze').disabled = true;
document.getElementById('btnSlice').disabled = true;
try {
var res = await fetch('/audio-slicer/upload', { method: 'POST', body: fd });
var data = await res.json();
if (!data.success) {
showMessage(data.error, 'error');
document.getElementById('uploadLabel').textContent = '点击或拖拽音频文件到此处';
return;
}
taskId = data.task_id;
var info = data.info;
document.getElementById('uploadLabel').textContent = '已选择文件';
document.getElementById('uploadFilename').textContent = data.filename;
document.getElementById('uploadFilename').style.display = 'block';
document.getElementById('audioInfo').style.display = 'grid';
document.getElementById('infoSr').textContent = info.sample_rate + ' Hz';
document.getElementById('infoCh').textContent = info.channels === 1 ? '单声道' : info.channels === 2 ? '立体声' : info.channels + '声道';
document.getElementById('infoDur').textContent = formatDur(info.duration);
document.getElementById('infoName').textContent = data.filename;
document.getElementById('btnAnalyze').disabled = false;
// 存储时长用于手动模式
audioDuration = info.duration;
cutPoints = [];
cutPointsChanged = false;
waveformPeaks = null;
// 重置右侧面板
document.getElementById('emptyState').style.display = 'flex';
document.getElementById('resultArea').style.display = 'none';
document.getElementById('timelineArea').style.display = 'none';
document.getElementById('redoWrap').style.display = 'none';
document.getElementById('sliceList').innerHTML = '';
document.getElementById('topActions').style.display = 'none';
// 两种模式都显示时间轴(自动模式只读,手动模式可交互)
showTimeline(cutMode === 'auto');
updateManualBtn();
// 异步加载波形数据
loadWaveform();
} catch (e) {
showMessage('上传失败: ' + e.message, 'error');
document.getElementById('uploadLabel').textContent = '点击或拖拽音频文件到此处';
}
}
async function doAnalyze() {
if (!taskId) return;
var btn = document.getElementById('btnAnalyze');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-refresh"></i> 分析中...';
document.getElementById('emptyState').style.display = 'none';
document.getElementById('resultArea').style.display = 'block';
document.getElementById('statusText').textContent = '正在分析音频...';
document.getElementById('sliceList').innerHTML = '';
try {
var params = Object.assign({ task_id: taskId }, getSettings());
var res = await fetch('/audio-slicer/analyze', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify(params)
});
var data = await res.json();
if (!data.success) {
showMessage(data.error, 'error');
document.getElementById('statusText').textContent = '分析失败: ' + data.error;
return;
}
document.getElementById('statusText').textContent = '分析完成: 检测到 ' + data.count + ' 个片段';
renderPreview(data.preview, data.sample_rate);
document.getElementById('btnSlice').disabled = false;
// 自动模式也显示时间轴(只读)
showTimeline(true);
} catch (e) {
showMessage('分析失败: ' + e.message, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-search"></i> 分析';
}
}
function renderPreview(preview, sr) {
// 默认全部选中
selectedSegments = [];
for (var i = 0; i < preview.length; i++) selectedSegments.push(i);
// 计算每段起止秒数(用于切割前预览播放)
window._previewRanges = [];
var cumSamples = 0;
for (var i = 0; i < preview.length; i++) {
var startSec = cumSamples / sr;
cumSamples += preview[i].samples;
var endSec = cumSamples / sr;
window._previewRanges.push([startSec, endSec]);
}
var html = '<div style="margin-bottom:10px;">'
+ '<span style="font-size:13px;font-weight:600;color:#333;"><i class="fas fa-list-check" style="color:#0078d4;margin-right:4px;"></i> 片段选择</span>'
+ '<span style="font-size:11px;color:#999;margin-left:8px;">点击取消勾选不需要的片段</span>'
+ '</div>'
+ '<div class="seg-ops">'
+ '<button class="seg-op-btn" onclick="selectAllSegments()">全选</button>'
+ '<button class="seg-op-btn" onclick="deselectAllSegments()">全不选</button>'
+ '<span id="segCount" style="font-size:11px;color:#999;margin-left:4px;">已选 ' + preview.length + '/' + preview.length + '</span>'
+ '</div>';
for (var i = 0; i < preview.length; i++) {
var p = preview[i];
html += '<div class="seg-row selected" id="segRow' + i + '" onclick="toggleSegment(' + i + ')">'
+ '<input type="checkbox" class="seg-check" id="segCb' + i + '" checked tabindex="-1">'
+ '<span style="width:24px;font-size:13px;font-weight:700;color:#999;text-align:center;">' + (i + 1) + '</span>'
+ '<span style="flex:1;font-size:13px;color:#333;">' + formatDur(p.duration) + '</span>'
+ '<span style="font-size:11px;color:#999;">' + p.samples.toLocaleString() + ' samples</span>'
+ '<button class="slice-btn play-btn" onclick="event.stopPropagation();playPreviewSeg(' + i + ')" title="试听"><i class="fas fa-play"></i></button>'
+ '</div>';
}
document.getElementById('sliceList').innerHTML = html;
}
function toggleSegment(idx) {
var pos = selectedSegments.indexOf(idx);
if (pos >= 0) {
selectedSegments.splice(pos, 1);
} else {
selectedSegments.push(idx);
selectedSegments.sort(function(a, b) { return a - b; });
}
// Sync checkbox visual state
var cb = document.getElementById('segCb' + idx);
if (cb) cb.checked = (pos < 0);
updateSegUI();
}
function selectAllSegments() {
var rows = document.querySelectorAll('.seg-row');
selectedSegments = [];
for (var i = 0; i < rows.length; i++) selectedSegments.push(i);
updateSegUI();
}
function deselectAllSegments() {
selectedSegments = [];
updateSegUI();
}
function updateSegUI() {
var rows = document.querySelectorAll('.seg-row');
for (var i = 0; i < rows.length; i++) {
var sel = selectedSegments.indexOf(i) >= 0;
rows[i].className = 'seg-row' + (sel ? ' selected' : ' dimmed');
var cb = document.getElementById('segCb' + i);
if (cb) cb.checked = sel;
}
var total = rows.length;
var el = document.getElementById('segCount');
if (el) el.textContent = '已选 ' + selectedSegments.length + '/' + total;
var btnSlice = document.getElementById('btnSlice');
if (btnSlice) btnSlice.disabled = selectedSegments.length === 0;
_updateSegmentOverlay();
}
async function doSlice() {
if (!taskId) return;
if (selectedSegments.length === 0) { showMessage('请至少选择一个片段', 'error'); return; }
var btn = document.getElementById('btnSlice');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-refresh"></i> 切割中...';
document.getElementById('btnAnalyze').disabled = true;
document.getElementById('progressWrap').style.display = 'block';
document.getElementById('progressFill').style.width = '0%';
document.getElementById('statusText').textContent = '正在切割...';
document.getElementById('topActions').style.display = 'none';
document.getElementById('sliceList').innerHTML = '';
try {
var res = await fetch('/audio-slicer/slice', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ task_id: taskId, selected_indices: selectedSegments })
});
var data = await res.json();
if (!data.success) {
showMessage(data.error, 'error');
return;
}
// 开始轮询
startPoll();
} catch (e) {
showMessage('请求失败: ' + e.message, 'error');
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-scissors"></i> 开始切割';
document.getElementById('btnAnalyze').disabled = false;
}
}
function startPoll() {
if (pollTimer) clearInterval(pollTimer);
pollTimer = setInterval(async function() {
try {
var res = await fetch('/audio-slicer/status/' + taskId);
var data = await res.json();
if (!data.success) return;
document.getElementById('progressFill').style.width = data.progress + '%';
document.getElementById('statusText').textContent = '切割进度: ' + data.progress + '%';
if (data.status === 'done') {
clearInterval(pollTimer);
pollTimer = null;
document.getElementById('statusText').textContent = '切割完成! 共 ' + data.count + ' 个片段';
document.getElementById('progressWrap').style.display = 'none';
renderSlices(data.slices);
document.getElementById('topActions').style.display = 'flex';
document.getElementById('btnSlice').disabled = false;
document.getElementById('btnSlice').innerHTML = '<i class="fas fa-scissors"></i> 开始切割';
document.getElementById('btnAnalyze').disabled = false;
document.getElementById('btnManualSlice').disabled = false;
document.getElementById('btnManualSlice').innerHTML = '<i class="fas fa-scissors"></i> 重新切割';
// 手动模式下保持时间轴可见,支持拖动调整后重新切割
if (cutMode === 'manual') {
document.getElementById('timelineArea').style.display = 'block';
cutPointsChanged = false;
document.getElementById('redoWrap').style.display = 'none';
}
} else if (data.status === 'error') {
clearInterval(pollTimer);
pollTimer = null;
document.getElementById('statusText').textContent = '切割失败: ' + data.error;
document.getElementById('progressWrap').style.display = 'none';
document.getElementById('btnSlice').disabled = false;
document.getElementById('btnSlice').innerHTML = '<i class="fas fa-scissors"></i> 开始切割';
document.getElementById('btnAnalyze').disabled = false;
document.getElementById('btnManualSlice').disabled = false;
document.getElementById('btnManualSlice').innerHTML = '<i class="fas fa-scissors"></i> 重新切割';
}
} catch (e) {}
}, 500);
}
function renderSlices(slices) {
var html = '';
for (var i = 0; i < slices.length; i++) {
var s = slices[i];
html += '<div class="slice-row">'
+ '<span class="slice-idx">' + (i + 1) + '</span>'
+ '<span class="slice-name" title="' + s.filename + '">' + s.filename + '</span>'
+ '<span class="slice-dur">' + formatDur(s.duration) + '</span>'
+ '<div class="slice-actions">'
+ '<button class="slice-btn play-btn" onclick="playSlice(' + i + ')" title="播放"><i class="fas fa-play"></i></button>'
+ '<button class="slice-btn" onclick="downloadSlice(' + i + ')" title="下载"><i class="fas fa-file"></i></button>'
+ '</div></div>';
}
document.getElementById('sliceList').innerHTML = html;
}
var currentAudio = null;
function downloadSlice(index) {
var a = document.createElement('a');
a.href = '/audio-slicer/download/' + taskId + '/' + index;
a.download = '';
a.click();
}
function downloadAll() {
var a = document.createElement('a');
a.href = '/audio-slicer/download-all/' + taskId;
a.download = '';
a.click();
}
// ── 模式切换 ──────────────────────────────────────────────────────
function setCutMode(mode) {
cutMode = mode;
document.getElementById('modeAuto').classList.toggle('active', mode === 'auto');
document.getElementById('modeManual').classList.toggle('active', mode === 'manual');
document.getElementById('autoParamsCard').style.display = mode === 'auto' ? 'block' : 'none';
document.getElementById('manualCutCard').style.display = mode === 'manual' ? 'block' : 'none';
document.getElementById('btnAnalyze').style.display = mode === 'auto' ? '' : 'none';
document.getElementById('btnSlice').style.display = mode === 'auto' ? '' : 'none';
document.getElementById('btnManualSlice').style.display = mode === 'manual' ? '' : 'none';
// 切换时清空右侧面板
document.getElementById('resultArea').style.display = 'none';
document.getElementById('redoWrap').style.display = 'none';
document.getElementById('sliceList').innerHTML = '';
document.getElementById('topActions').style.display = 'none';
document.getElementById('progressWrap').style.display = 'none';
cutPointsChanged = false;
// 两种模式都显示时间轴(自动模式只读,手动模式可交互)
if (audioDuration > 0) {
showTimeline(mode === 'auto');
}
}
function showTimeline(readOnly) {
document.getElementById('emptyState').style.display = 'none';
document.getElementById('timelineArea').style.display = 'block';
renderTimeline(readOnly);
}
function updateManualBtn() {
var btn = document.getElementById('btnManualSlice');
if (btn) btn.disabled = !(taskId && cutPoints.length > 0 && manualSegSelected.length > 0);
}
// ── 切割点管理 ────────────────────────────────────────────────────
function addCutPoint(sec) {
sec = Math.round(sec * 100) / 100;
if (sec <= 0 || sec >= audioDuration) return;
for (var i = 0; i < cutPoints.length; i++) {
if (Math.abs(cutPoints[i] - sec) < 0.05) return;
}
cutPoints.push(sec);
cutPoints.sort(function(a, b) { return a - b; });
manualSegInit = true;
renderCutList();
renderTimeline();
updateManualBtn();
markCutPointsChanged();
}
function removeCutPoint(idx) {
cutPoints.splice(idx, 1);
manualSegInit = true;
renderCutList();
renderTimeline();
updateManualBtn();
markCutPointsChanged();
}
function addCutPointRow() {
if (audioDuration <= 0) { showMessage('请先上传音频', 'error'); return; }
// 默认添加到中间位置
var sec = audioDuration / 2;
if (cutPoints.length > 0) {
// 找最大的间隔中点
var maxGap = 0, maxIdx = 0;
var pts = [0].concat(cutPoints).concat([audioDuration]);
for (var i = 1; i < pts.length; i++) {
var gap = pts[i] - pts[i - 1];
if (gap > maxGap) { maxGap = gap; maxIdx = i; }
}
sec = (pts[maxIdx - 1] + pts[maxIdx]) / 2;
}
addCutPoint(sec);
}
function renderCutList() {
var list = document.getElementById('cutList');
if (!list) return;
if (cutPoints.length === 0) {
list.innerHTML = '<div style="text-align:center;color:#ccc;font-size:12px;padding:8px;">暂无切割点</div>';
manualSegSelected = [];
return;
}
// 新预览时重置勾选
if (manualSegInit) {
manualSegInit = false;
}
var html = '';
for (var i = 0; i < cutPoints.length; i++) {
html += '<div class="cut-row">'
+ '<span class="cut-idx"><i class="fas fa-scissors" style="font-size:10px;color:#e53935;"></i></span>'
+ '<input class="cut-input" value="' + formatTime(cutPoints[i]) + '" '
+ 'onchange="updateCutPoint(' + i + ', this.value)" '
+ 'onkeydown="if(event.key===\'Enter\')this.blur()">'
+ '<button class="cut-del" onclick="removeCutPoint(' + i + ')" title="删除"><i class="fas fa-times"></i></button>'
+ '</div>';
}
var boundaries = [0].concat(cutPoints).concat([audioDuration]);
// 仅初始化时全选;用户已手动调整勾选则保留
if (manualSegSelected.length !== boundaries.length - 1 && !manualSegChanged) {
manualSegSelected = [];
for (var j = 0; j < boundaries.length - 1; j++) manualSegSelected.push(j);
}
html += '<div style="border-top:1px solid #e0e0e0;margin-top:8px;padding-top:8px;">'
+ '<div style="font-size:11px;font-weight:600;color:#999;margin-bottom:6px;">'
+ '<i class="fas fa-list-check" style="color:#0078d4;margin-right:3px;"></i>预览片段 '
+ '<span style="font-weight:400;color:#aaa;">点击取消勾选不需要的片段</span>'
+ '</div>'
+ '<div class="seg-ops">'
+ '<button class="seg-op-btn" onclick="selectManualSegments()">全选</button>'
+ '<span id="manualSegCount" style="font-size:11px;color:#999;margin-left:4px;"></span>'
+ '</div>';
for (var i = 0; i < boundaries.length - 1; i++) {
var s = boundaries[i], e = boundaries[i + 1];
var dur = e - s;
var sel = manualSegSelected.indexOf(i) >= 0;
html += '<div class="seg-row' + (sel ? ' selected' : ' dimmed') + '" onclick="toggleManualSeg(' + i + ')">'
+ '<input type="checkbox" class="seg-check" id="mSegCb' + i + '"' + (sel ? ' checked' : '') + ' tabindex="-1">'
+ '<span style="width:24px;font-size:13px;font-weight:700;color:#999;text-align:center;">' + (i + 1) + '</span>'
+ '<span style="flex:1;font-size:12px;color:#555;">' + formatTime(s) + ' → ' + formatTime(e) + '</span>'
+ '<span style="font-size:11px;color:#999;width:50px;text-align:right;">' + formatDur(dur) + '</span>'
+ '<button class="cut-del" style="color:#0078d4;" onclick="event.stopPropagation();playPreview(' + s + ',' + e + ')" title="试听"><i class="fas fa-play"></i></button>'
+ '</div>';
}
html += '</div>';
list.innerHTML = html;
updateManualSegCount();
}
function updateCutPoint(idx, val) {
var sec = parseTime(val);
if (isNaN(sec) || sec <= 0 || sec >= audioDuration) {
showMessage('无效的时间', 'error');
renderCutList();
return;
}
cutPoints[idx] = Math.round(sec * 100) / 100;
cutPoints.sort(function(a, b) { return a - b; });
renderCutList();
renderTimeline();
markCutPointsChanged();
}
function toggleManualSeg(idx) {
var pos = manualSegSelected.indexOf(idx);
if (pos >= 0) {
manualSegSelected.splice(pos, 1);
} else {
manualSegSelected.push(idx);
manualSegSelected.sort(function(a, b) { return a - b; });
}
manualSegChanged = true;
var cb = document.getElementById('mSegCb' + idx);
if (cb) cb.checked = (pos < 0);
var row = cb ? cb.closest('.seg-row') : null;
if (row) row.className = 'seg-row' + (pos < 0 ? ' selected' : ' dimmed');
updateManualSegCount();
updateManualBtn();
_updateSegmentOverlay();
}
function selectManualSegments() {
manualSegSelected = [];
for (var i = 0; i < cutPoints.length + 1; i++) manualSegSelected.push(i);
manualSegChanged = true;
renderCutList();
_updateSegmentOverlay();
}
function updateManualSegCount() {
var total = cutPoints.length + 1;
var el = document.getElementById('manualSegCount');
if (el) el.textContent = '已选 ' + manualSegSelected.length + '/' + total;
}
function formatTime(sec) {
var m = Math.floor(sec / 60);
var s = (sec % 60).toFixed(2);
if (s < 10) s = '0' + s;
return m + ':' + s;
}
function parseTime(str) {
str = str.trim();
if (str.indexOf(':') !== -1) {
var parts = str.split(':');
return parseFloat(parts[0]) * 60 + parseFloat(parts[1]);
}
return parseFloat(str);
}
// ── 时间轴 (HTML div 实现,参考 audio-cutter 项目) ────────────────
var waveformPeaks = null;
function renderTimeline(readOnly) {
var bar = document.getElementById('timelineBar');
if (!bar || audioDuration <= 0) return;
bar.className = readOnly ? 'readonly' : '';
var html = '<canvas id="waveformCanvas"></canvas>'
+ '<div id="segmentOverlay"></div>'
+ '<div id="playCursor"><div id="cursorHandle"></div></div>';
// 时间刻度(在波形条上方)
var step = Math.max(1, Math.ceil(audioDuration / 10));
for (var t = 0; t <= audioDuration; t += step) {
var pct = (t / audioDuration * 100).toFixed(2);
html += '<div style="position:absolute;left:' + pct + '%;top:0;bottom:0;border-left:1px solid rgba(0,0,0,0.08);z-index:0;"></div>'
+ '<div style="position:absolute;left:' + pct + '%;top:-18px;transform:translateX(-50%);font-size:10px;color:#999;pointer-events:none;">' + formatTime(t) + '</div>';
}
// 切割标记 div倒三角在上时间在下
for (var i = 0; i < cutPoints.length; i++) {
var pct = (cutPoints[i] / audioDuration * 100).toFixed(2);
html += '<div class="cut-marker" data-idx="' + i + '" style="left:' + pct + '%;">'
+ '<div class="cut-marker-tri"></div>'
+ '<div class="cut-marker-line"></div>'
+ '<div class="cut-marker-label">' + formatTime(cutPoints[i]) + '</div></div>';
}
bar.innerHTML = html;
// 绘制波形
if (waveformPeaks) drawWaveform();
_updateSegmentOverlay();
// 播放控制条
var pc = document.getElementById('playerControls');
if (pc) { pc.style.display = audioDuration > 0 ? '' : 'none'; }
var pt = document.getElementById('playerTotal');
if (pt) pt.textContent = formatTime(audioDuration);
_showCursorAtCurrent();
// 绑定事件(只绑一次)
if (!bar._bound) {
bar._bound = true;
// 时间轴点击:只读模式 → seek手动模式 → 添加切割点
bar.addEventListener('click', function(e) {
if (bar._suppressClick) { bar._suppressClick = false; return; }
if (e.target.closest('.cut-marker') || e.target.closest('#playCursor')) return;
var rect = bar.getBoundingClientRect();
var sec = (e.clientX - rect.left) / rect.width * audioDuration;
if (bar.classList.contains('readonly')) {
playerSeekTo(sec);
return;
}
// 手动模式:不允许在灰色片段内添加切割点
var ranges = _getSegmentRanges();
var sel = _getSelectedSet();
if (ranges.length && sel.length) {
for (var i = 0; i < ranges.length; i++) {
if (sec >= ranges[i][0] && sec <= ranges[i][1]) {
if (sel.indexOf(i) < 0) return;
break;
}
}
}
if (sec > 0.5 && sec < audioDuration - 0.5) addCutPoint(sec);
});
// 播放游标拖拽
_bindCursorDrag(bar);
// 进度条拖拽 seek
var seekBar = document.getElementById('playerSeekBar');
if (seekBar) {
seekBar.addEventListener('input', function() {
var sec = (parseFloat(this.value) / 1000) * audioDuration;
var cursor = document.getElementById('playCursor');
var timeEl = document.getElementById('playerTime');
if (cursor) { cursor.style.display = 'block'; cursor.style.left = (sec / audioDuration * 100).toFixed(2) + '%'; }
if (timeEl) timeEl.textContent = formatTime(sec);
if (playerAudio) {
var rel = sec - _playerStartSec;
var dur = _playerEndSec - _playerStartSec;
if (rel >= 0 && rel <= dur) playerAudio.currentTime = rel;
}
});
}
}
// 为每个标记绑定拖动
var markers = bar.querySelectorAll('.cut-marker');
for (var i = 0; i < markers.length; i++) {
bindMarkerDrag(markers[i], bar);
}
}
function drawWaveform() {
var canvas = document.getElementById('waveformCanvas');
if (!canvas || !waveformPeaks || waveformPeaks.length === 0) return;
var bar = document.getElementById('timelineBar');
canvas.width = bar.clientWidth;
canvas.height = bar.clientHeight;
var ctx = canvas.getContext('2d');
var W = canvas.width, H = canvas.height;
var peaks = waveformPeaks;
var barW = W / peaks.length;
var midY = H / 2;
// 背景渐变
var grad = ctx.createLinearGradient(0, 0, 0, H);
grad.addColorStop(0, '#b3d4fc');
grad.addColorStop(0.5, '#0078d4');
grad.addColorStop(1, '#b3d4fc');
ctx.fillStyle = grad;
for (var i = 0; i < peaks.length; i++) {
var h = peaks[i] * midY * 0.9;
var x = i * barW;
ctx.fillRect(x, midY - h, Math.max(barW - 0.5, 1), h * 2);
}
}
// ── 片段灰色覆盖(取消选中的片段变灰) ──
function _getSegmentRanges() {
// 手动模式:从 cutPoints 计算
if (cutMode === 'manual') {
if (cutPoints.length > 0) {
var b = [0].concat(cutPoints.slice().sort(function(a,b){return a-b;})).concat([audioDuration]);
var ranges = [];
for (var i = 0; i < b.length - 1; i++) ranges.push([b[i], b[i+1]]);
return ranges;
}
return [];
}
// 自动模式:用预览区间
if (window._previewRanges && window._previewRanges.length) {
return window._previewRanges;
}
return [];
}
function _getSelectedSet() {
if (manualSegSelected && manualSegSelected.length > 0 && cutPoints.length > 0) {
return manualSegSelected;
}
return selectedSegments;
}
function _updateSegmentOverlay() {
var overlay = document.getElementById('segmentOverlay');
if (!overlay || audioDuration <= 0) return;
var ranges = _getSegmentRanges();
if (!ranges.length) { overlay.innerHTML = ''; return; }
var sel = _getSelectedSet();
var html = '';
for (var i = 0; i < ranges.length; i++) {
if (sel.indexOf(i) < 0) {
var left = (ranges[i][0] / audioDuration * 100).toFixed(2);
var width = ((ranges[i][1] - ranges[i][0]) / audioDuration * 100).toFixed(2);
html += '<div class="seg-gray" style="left:' + left + '%;width:' + width + '%;"></div>';
}
}
overlay.innerHTML = html;
}
// ── 播放器系统 ──
function _showCursorAtCurrent() {
var cursor = document.getElementById('playCursor');
if (!cursor) return;
if (playerAudio) {
var sec = _playerStartSec + playerAudio.currentTime;
cursor.style.left = (sec / audioDuration * 100).toFixed(2) + '%';
}
}
function _playerTick() {
var cursor = document.getElementById('playCursor');
var timeEl = document.getElementById('playerTime');
if (!playerAudio || !cursor) return;
var sec = _playerStartSec + playerAudio.currentTime;
cursor.style.left = (sec / audioDuration * 100).toFixed(2) + '%';
if (timeEl) timeEl.textContent = formatTime(sec);
var seekBar = document.getElementById('playerSeekBar');
if (seekBar && audioDuration > 0) seekBar.value = (sec / audioDuration * 1000).toFixed(0);
_playerRAF = requestAnimationFrame(_playerTick);
}
function playerStartTick() {
playerStopTick();
var cursor = document.getElementById('playCursor');
if (cursor) cursor.style.display = 'block';
_playerRAF = requestAnimationFrame(_playerTick);
}
function playerStopTick() {
if (_playerRAF) { cancelAnimationFrame(_playerRAF); _playerRAF = null; }
}
function playerSeekTo(sec) {
sec = Math.max(0, Math.min(audioDuration, sec));
var cursor = document.getElementById('playCursor');
var timeEl = document.getElementById('playerTime');
if (cursor) {
cursor.style.display = 'block';
cursor.style.left = (sec / audioDuration * 100).toFixed(2) + '%';
}
if (timeEl) timeEl.textContent = formatTime(sec);
if (playerAudio && !playerAudio.paused) {
var rel = sec - _playerStartSec;
if (rel >= 0 && rel <= _playerEndSec - _playerStartSec) {
playerAudio.currentTime = rel;
}
}
}
function playerToggle() {
if (playerPlaying) {
playerPause();
} else {
playerPlay();
}
}
function playerPlay() {
if (!taskId || audioDuration <= 0) return;
if (playerAudio && !playerAudio.paused) return;
// 恢复暂停的音频
if (playerAudio && playerAudio.paused && playerAudio.currentTime > 0 && playerAudio.duration > 0) {
playerAudio.play();
playerPlaying = true;
playerStartTick();
_updatePlayerIcon();
return;
}
// 新建全曲播放
_playerStopAll();
_playerMode = 'full';
_playerStartSec = 0;
_playerEndSec = audioDuration;
playerAudio = new Audio('/audio-slicer/preview-range/' + taskId + '?start=0&end=' + audioDuration);
playerAudio.addEventListener('ended', function() {
playerPlaying = false;
playerStopTick();
_updatePlayerIcon();
});
playerAudio.play();
playerPlaying = true;
playerStartTick();
_updatePlayerIcon();
}
function playerPause() {
if (playerAudio) playerAudio.pause();
playerPlaying = false;
playerStopTick();
_updatePlayerIcon();
}
function _playerStopAll() {
playerStopTick();
if (playerAudio) {
playerAudio.pause();
playerAudio.src = '';
playerAudio = null;
}
playerPlaying = false;
_updatePlayerIcon();
if (currentAudio) { currentAudio.pause(); currentAudio = null; }
}
function _updatePlayerIcon() {
var icon = document.getElementById('playerIcon');
if (icon) icon.className = playerPlaying ? 'fas fa-pause' : 'fas fa-play';
}
// 播放片段(自动模式列表按钮)
function playPreviewSeg(index) {
if (!taskId || !window._previewRanges || !window._previewRanges[index]) return;
_playerStopAll();
var r = window._previewRanges[index];
_playerMode = 'seg';
_playerStartSec = r[0];
_playerEndSec = r[1];
playerAudio = new Audio('/audio-slicer/preview-range/' + taskId + '?start=' + r[0] + '&end=' + r[1]);
playerAudio.addEventListener('ended', function() {
playerPlaying = false;
playerStopTick();
_updatePlayerIcon();
});
playerAudio.play();
playerPlaying = true;
playerStartTick();
_updatePlayerIcon();
}
// 播放片段(手动模式列表按钮)
function playPreview(start, end) {
if (!taskId) return;
_playerStopAll();
_playerMode = 'seg';
_playerStartSec = start;
_playerEndSec = end;
playerAudio = new Audio('/audio-slicer/preview-range/' + taskId + '?start=' + start + '&end=' + end);
playerAudio.addEventListener('ended', function() {
playerPlaying = false;
playerStopTick();
_updatePlayerIcon();
});
playerAudio.play();
playerPlaying = true;
playerStartTick();
_updatePlayerIcon();
}
// 播放已切片结果
function playSlice(index) {
_playerStopAll();
var audio = new Audio('/audio-slicer/download/' + taskId + '/' + index);
audio.play();
currentAudio = audio;
}
// 游标拖拽
function _bindCursorDrag(bar) {
var cursor = document.getElementById('playCursor');
if (!cursor) return;
var dragging = false;
cursor.addEventListener('pointerdown', function(e) {
e.preventDefault();
e.stopPropagation();
dragging = true;
cursor.setPointerCapture(e.pointerId);
});
cursor.addEventListener('pointermove', function(e) {
if (!dragging) return;
var rect = bar.getBoundingClientRect();
var sec = (e.clientX - rect.left) / rect.width * audioDuration;
sec = Math.max(0, Math.min(audioDuration, sec));
cursor.style.left = (sec / audioDuration * 100).toFixed(2) + '%';
var timeEl = document.getElementById('playerTime');
if (timeEl) timeEl.textContent = formatTime(sec);
// 同步 audio currentTime
if (playerAudio) {
var rel = sec - _playerStartSec;
var dur = _playerEndSec - _playerStartSec;
if (rel >= 0 && rel <= dur) {
playerAudio.currentTime = rel;
}
}
});
cursor.addEventListener('pointerup', function(e) {
if (!dragging) return;
dragging = false;
cursor.releasePointerCapture(e.pointerId);
bar._suppressClick = true;
});
}
async function loadWaveform() {
if (!taskId) return;
try {
var res = await fetch('/audio-slicer/waveform-peaks/' + taskId + '?samples=500');
var data = await res.json();
if (data.peaks) {
var max = 0;
for (var i = 0; i < data.peaks.length; i++) {
if (data.peaks[i] > max) max = data.peaks[i];
}
if (max > 0) {
for (var i = 0; i < data.peaks.length; i++) {
data.peaks[i] = data.peaks[i] / max;
}
}
waveformPeaks = data.peaks;
drawWaveform();
}
} catch (e) {}
}
function bindMarkerDrag(el, bar) {
el.addEventListener('pointerdown', function(e) {
if (e.button === 2) return;
e.preventDefault();
e.stopPropagation();
el.style.touchAction = 'none';
var startX = e.clientX;
var hasMoved = false;
var idx = parseInt(el.dataset.idx);
function onMove(e2) {
e2.preventDefault();
var dx = e2.clientX - startX;
if (!hasMoved && Math.abs(dx) >= 5) {
hasMoved = true;
el.style.cursor = 'grabbing';
el.style.zIndex = '10';
}
if (hasMoved) {
var rect = bar.getBoundingClientRect();
var sec = (e2.clientX - rect.left) / rect.width * audioDuration;
sec = Math.round(sec * 100) / 100;
sec = Math.max(0.1, Math.min(audioDuration - 0.1, sec));
cutPoints[idx] = sec;
el.style.left = (sec / audioDuration * 100).toFixed(2) + '%';
el.querySelector('.cut-marker-label').textContent = formatTime(sec);
startX = e2.clientX;
}
}
function onUp() {
document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', onUp);
el.style.touchAction = '';
el.style.cursor = '';
el.style.zIndex = '';
if (hasMoved) {
cutPoints.sort(function(a, b) { return a - b; });
renderCutList();
renderTimeline();
markCutPointsChanged();
bar._suppressClick = true;
} else {
removeCutPoint(idx);
}
}
document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', onUp);
});
}
// ── 手动切割 ──────────────────────────────────────────────────────
async function doManualSlice() {
if (!taskId || cutPoints.length === 0) return;
if (manualSegSelected.length === 0) { showMessage('请至少选择一个片段', 'error'); return; }
var btn = document.getElementById('btnManualSlice');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-refresh"></i> 处理中...';
// 先提交切割点获取片段
try {
var res = await fetch('/audio-slicer/manual-slice', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ task_id: taskId, cut_points: cutPoints })
});
var data = await res.json();
if (!data.success) {
showMessage(data.error, 'error');
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-scissors"></i> 开始切割';
return;
}
document.getElementById('emptyState').style.display = 'none';
document.getElementById('resultArea').style.display = 'block';
document.getElementById('statusText').textContent = '已标记 ' + data.count + ' 段,开始切割...';
renderPreview(data.preview, data.sample_rate);
} catch (e) {
showMessage('请求失败: ' + e.message, 'error');
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-scissors"></i> 开始切割';
return;
}
// 切割选中的片段
document.getElementById('progressWrap').style.display = 'block';
document.getElementById('progressFill').style.width = '0%';
document.getElementById('topActions').style.display = 'none';
document.getElementById('sliceList').innerHTML = '';
try {
var res = await fetch('/audio-slicer/slice', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ task_id: taskId, selected_indices: manualSegSelected })
});
var data = await res.json();
if (!data.success) {
showMessage(data.error, 'error');
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-scissors"></i> 开始切割';
return;
}
cutPointsChanged = false;
document.getElementById('redoWrap').style.display = 'none';
startPoll();
} catch (e) {
showMessage('请求失败: ' + e.message, 'error');
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-scissors"></i> 开始切割';
}
}
function markCutPointsChanged() {
if (!cutPointsChanged && document.getElementById('resultArea').style.display !== 'none') {
cutPointsChanged = true;
document.getElementById('redoWrap').style.display = 'block';
}
}
async function redoManualSlice() {
if (!taskId || cutPoints.length === 0) return;
if (manualSegSelected.length === 0) { showMessage('请至少选择一个片段', 'error'); return; }
document.getElementById('redoWrap').style.display = 'none';
cutPointsChanged = false;
// 跳过重新预览,直接用当前勾选切割
var btn = document.getElementById('btnManualSlice');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-refresh"></i> 切割中...';
document.getElementById('progressWrap').style.display = 'block';
document.getElementById('progressFill').style.width = '0%';
document.getElementById('statusText').textContent = '正在重新切割...';
document.getElementById('topActions').style.display = 'none';
document.getElementById('sliceList').innerHTML = '';
try {
var res = await fetch('/audio-slicer/slice', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ task_id: taskId, selected_indices: manualSegSelected })
});
var data = await res.json();
if (!data.success) {
showMessage(data.error, 'error');
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-scissors"></i> 开始切割';
return;
}
startPoll();
} catch (e) {
showMessage('请求失败: ' + e.message, 'error');
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-scissors"></i> 开始切割';
}
}
renderCutList();
</script>
</body>
</html>