Python音频音轨分离调整
```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>音频音轨分离与时间偏移工具</title>
<style>
* {
box-sizing: border-box;
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
}
body {
background: #f5f7fc;
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
margin: 0;
padding: 20px;
}
.card {
max-width: 900px;
width: 100%;
background: #ffffff;
border-radius: 32px;
box-shadow: 0 20px 40px rgba(0, 10, 30, 0.12), 0 8px 20px rgba(0, 0, 0, 0.05);
padding: 30px 30px 40px;
transition: all 0.2s ease;
}
h1 {
font-size: 1.9rem;
font-weight: 600;
color: #0b1a33;
margin-top: 0;
margin-bottom: 8px;
letter-spacing: -0.3px;
display: flex;
align-items: center;
gap: 10px;
}
.subhead {
color: #4a5a72;
margin-top: -4px;
margin-bottom: 28px;
font-size: 0.95rem;
border-left: 4px solid #3b7cff;
padding-left: 16px;
background: #f0f4fe;
border-radius: 0 12px 12px 0;
line-height: 1.5;
}
.upload-area {
background: #f8faff;
border: 2px dashed #cbd5e1;
border-radius: 24px;
padding: 28px 20px;
text-align: center;
transition: 0.2s;
cursor: pointer;
margin-bottom: 28px;
}
.upload-area:hover {
background: #f0f6ff;
border-color: #3b7cff;
}
.upload-area input[type="file"] {
display: none;
}
.upload-label {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
font-weight: 500;
color: #1e2b45;
}
.upload-label span:first-child {
font-size: 2.4rem;
}
.upload-label .hint {
font-size: 0.9rem;
color: #5f6f88;
font-weight: 400;
}
.file-status {
margin-top: 12px;
font-size: 0.95rem;
color: #0b1a33;
background: #e9effa;
padding: 8px 16px;
border-radius: 40px;
display: inline-block;
}
.track-list {
display: flex;
flex-direction: column;
gap: 16px;
margin: 24px 0 30px;
max-height: 420px;
overflow-y: auto;
padding-right: 4px;
}
.track-item {
background: #f9fbfe;
border-radius: 20px;
padding: 16px 20px;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px 18px;
border: 1px solid #e6edf6;
transition: 0.15s;
}
.track-item:hover {
background: #f2f7ff;
border-color: #b6cef0;
}
.track-index {
background: #d2def5;
color: #1d2d4a;
width: 32px;
height: 32px;
border-radius: 40px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 0.9rem;
}
.track-name {
flex: 2 1 140px;
font-weight: 500;
color: #14273e;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.track-control {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
flex: 3 1 260px;
}
.track-control label {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.9rem;
color: #2f405b;
white-space: nowrap;
}
.track-control input[type="range"] {
width: 120px;
height: 6px;
-webkit-appearance: none;
background: #d0dcee;
border-radius: 12px;
outline: none;
}
.track-control input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 16px;
height: 16px;
border-radius: 20px;
background: #1e5eff;
cursor: pointer;
border: 2px solid white;
box-shadow: 0 2px 8px rgba(0, 60, 200, 0.3);
}
.offset-value {
min-width: 58px;
font-weight: 500;
color: #0a1b36;
background: #eaf0fa;
padding: 4px 10px;
border-radius: 30px;
text-align: center;
font-size: 0.9rem;
}
.track-actions {
display: flex;
gap: 8px;
margin-left: auto;
}
.btn-icon {
background: transparent;
border: 1px solid #ccd9ed;
border-radius: 30px;
padding: 6px 14px;
font-size: 0.8rem;
font-weight: 500;
color: #1f3b60;
cursor: pointer;
transition: 0.15s;
display: inline-flex;
align-items: center;
gap: 6px;
}
.btn-icon:hover {
background: #e2ecfe;
border-color: #3b7cff;
}
.btn-icon.solo {
background: #d9e6ff;
border-color: #3b7cff;
}
.btn-icon.solo:hover {
background: #c0d6ff;
}
.global-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 18px;
padding-top: 18px;
border-top: 1px solid #e2eaf5;
margin-top: 6px;
}
.playback-buttons {
display: flex;
gap: 12px;
}
.btn-primary {
background: #1e5eff;
border: none;
color: white;
padding: 10px 28px;
border-radius: 60px;
font-weight: 600;
font-size: 1rem;
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
transition: 0.15s;
box-shadow: 0 6px 14px rgba(30, 94, 255, 0.25);
border: 1px solid #1e5eff;
}
.btn-primary:hover {
background: #1048e0;
transform: scale(0.98);
box-shadow: 0 4px 10px rgba(30, 94, 255, 0.3);
}
.btn-secondary {
background: transparent;
border: 1px solid #b8cae3;
color: #1f3b60;
padding: 10px 22px;
border-radius: 60px;
font-weight: 500;
font-size: 1rem;
cursor: pointer;
transition: 0.15s;
}
.btn-secondary:hover {
background: #eaf1fd;
border-color: #3b7cff;
}
.btn-danger {
background: #f2f2f2;
border: 1px solid #d6d6d6;
color: #5f5f5f;
padding: 10px 22px;
border-radius: 60px;
font-weight: 500;
font-size: 1rem;
cursor: pointer;
transition: 0.15s;
}
.btn-danger:hover {
background: #ffe5e5;
border-color: #ff7a7a;
color: #a10000;
}
.status-badge {
font-size: 0.9rem;
background: #eef3f9;
padding: 6px 18px;
border-radius: 60px;
color: #1d3557;
display: inline-flex;
align-items: center;
gap: 8px;
}
.status-badge .dot {
width: 10px;
height: 10px;
border-radius: 10px;
background: #4caf50;
display: inline-block;
}
.status-badge .dot.inactive {
background: #a0aec0;
}
.footer-note {
margin-top: 20px;
font-size: 0.8rem;
color: #6b7d99;
text-align: center;
border-top: 1px solid #e5ecf5;
padding-top: 18px;
}
@media (max-width: 650px) {
.card { padding: 20px; }
.track-item { flex-direction: column; align-items: stretch; }
.track-control { flex-wrap: wrap; }
.track-actions { margin-left: 0; justify-content: flex-end; }
}
</style>
</head>
<body>
<div class="card">
<h1> 音轨分离 · 时间偏移</h1>
<div class="subhead">
上传音频 (MP3/WAV) 自动识别音轨 (最多8轨) · 拖动滑块独立调整播放开始时间
</div>
<!-- 上传区域 -->
<div class="upload-area" id="uploadArea">
<input type="file" id="fileInput" accept="audio/*" multiple>
<div class="upload-label">
<span></span>
<span><strong>点击上传 或 拖拽音频文件</strong></span>
<span class="hint">支持 MP3, WAV, AAC, OGG 等 (每个文件视为一个音轨)</span>
</div>
<div id="fileStatus" class="file-status">未加载音轨</div>
</div>
<!-- 音轨列表 -->
<div id="trackListContainer" class="track-list">
<!-- 动态生成 -->
</div>
<!-- 全局控制 -->
<div class="global-controls">
<div class="playback-buttons">
<button class="btn-primary" id="playBtn"> 播放</button>
<button class="btn-secondary" id="pauseBtn"> 暂停</button>
<button class="btn-secondary" id="stopBtn"> 停止</button>
</div>
<div>
<button class="btn-danger" id="resetBtn">↺ 重置偏移</button>
</div>
<div id="playStatus" class="status-badge"><span class="dot inactive"></span> 就绪</div>
</div>
<div class="footer-note">
每个音轨独立偏移 (单位: 秒) · 偏移量可正可负 · 所有音轨共享同一播放进度
</div>
</div>
<script>
(function() {
// ---------- DOM 引用 ----------
const fileInput = document.getElementById('fileInput');
const uploadArea = document.getElementById('uploadArea');
const fileStatus = document.getElementById('fileStatus');
const trackListContainer = document.getElementById('trackListContainer');
const playBtn = document.getElementById('playBtn');
const pauseBtn = document.getElementById('pauseBtn');
const stopBtn = document.getElementById('stopBtn');
const resetBtn = document.getElementById('resetBtn');
const playStatus = document.getElementById('playStatus');
const statusDot = playStatus.querySelector('.dot');
// ---------- 状态 ----------
let audioBuffers = []; // 存储每个音轨的 AudioBuffer
let trackOffsets = []; // 每个音轨的偏移量 (秒)
let trackNames = [];
let trackElements = []; // 用于 UI 更新
let audioContext = null;
let currentSources = []; // 当前播放的 source 节点
let gainNodes = []; // 每个音轨的增益节点 (用于独立控制)
let startTime = 0; // context.currentTime 开始播放的基准
let pausedAt = 0; // 暂停时的进度 (秒)
let isPlaying = false;
// 播放状态
const STATUS_IDLE = 'idle';
const STATUS_PLAYING = 'playing';
const STATUS_PAUSED = 'paused';
let playState = STATUS_IDLE;
// UI 更新防抖
let updateTimeout = null;
// ---------- 辅助函数 ----------
function formatOffset(sec) {
return sec >= 0 ? `+${sec.toFixed(2)}s` : `${sec.toFixed(2)}s`;
}
// 更新状态显示
function updatePlayStatus(state, msg) {
if (state === 'playing') {
statusDot.className = 'dot';
playStatus.innerHTML = `<span class="dot"></span> 播放中 ${msg ? '· ' + msg : ''}`;
} else if (state === 'paused') {
statusDot.className = 'dot inactive';
playStatus.innerHTML = `<span class="dot inactive"></span> 已暂停 ${msg ? '· ' + msg : ''}`;
} else {
statusDot.className = 'dot inactive';
playStatus.innerHTML = `<span class="dot inactive"></span> ${msg || '就绪'}`;
}
}
// 停止所有音轨 (释放资源)
function stopAllSources() {
currentSources.forEach(src => {
try { src.stop(0); } catch (e) {}
try { src.disconnect(); } catch (e) {}
});
currentSources = [];
gainNodes = [];
isPlaying = false;
playState = STATUS_IDLE;
updatePlayStatus('idle', '已停止');
}
// 重置播放状态 (不清理 buffer)
function resetPlaybackState() {
stopAllSources();
pausedAt = 0;
startTime = 0;
if (audioContext && audioContext.state !== 'closed') {
// 不关闭 context,避免重新创建
}
// 重置所有轨道 solo/静音状态视觉 (仅UI)
document.querySelectorAll('.solo').forEach(el => el.classList.remove('solo'));
updatePlayStatus('idle', '就绪');
}
// 构建音轨列表 UI
function renderTrackList() {
if (audioBuffers.length === 0) {
trackListContainer.innerHTML = `<div style="text-align:center; color:#7a8aa3; padding: 20px; background: #f7faff; border-radius: 40px;"> 上传音频文件后,音轨将在此显示</div>`;
return;
}
let html = '';
for (let i = 0; i < audioBuffers.length; i++) {
const name = trackNames[i] || `音轨 ${i+1}`;
const offset = trackOffsets[i] || 0;
const duration = audioBuffers[i] ? audioBuffers[i].duration.toFixed(2) : '--';
html += `
<div class="track-item" data-trackindex="${i}">
<div class="track-index">${i+1}</div>
<div class="track-name" title="${name}">${name}</div>
<div class="track-control">
<label>偏移 <input type="range" min="-10" max="10" step="0.05" value="${offset}" data-track="${i}" class="offset-slider"></label>
<span class="offset-value" id="offsetDisplay_${i}">${formatOffset(offset)}</span>
<span style="font-size:0.75rem; color:#5e7490;">(时长 ${duration}s)</span>
</div>
<div class="track-actions">
<button class="btn-icon solo-track" data-track="${i}"> 独奏</button>
</div>
</div>
`;
}
trackListContainer.innerHTML = html;
// 绑定滑块事件
document.querySelectorAll('.offset-slider').forEach(slider => {
slider.addEventListener('input', function(e) {
const idx = parseInt(this.dataset.track, 10);
const val = parseFloat(this.value);
trackOffsets[idx] = val;
const display = document.getElementById(`offsetDisplay_${idx}`);
if (display) display.textContent = formatOffset(val);
// 如果正在播放,需要重新调度 (简单起见,停止并重置)
if (isPlaying || playState === 'playing' || playState === 'paused') {
// 如果正在播放,停止并变为暂停状态
if (isPlaying) {
stopAllSources();
// 保存当前进度
if (audioContext) {
const elapsed = audioContext.currentTime - startTime;
pausedAt = Math.max(0, elapsed);
} else {
pausedAt = 0;
}
isPlaying = false;
playState = STATUS_PAUSED;
updatePlayStatus('paused', `偏移已调整, 进度 ${pausedAt.toFixed(2)}s`);
} else {
// 暂停状态下只更新显示
}
}
});
});
// 绑定独奏按钮
document.querySelectorAll('.solo-track').forEach(btn => {
btn.addEventListener('click', function(e) {
const idx = parseInt(this.dataset.track, 10);
// 切换独奏样式 (视觉反馈)
this.classList.toggle('solo');
// 实际独奏逻辑: 如果正在播放,重新播放只播放此轨 (或者静音其他)
if (audioBuffers.length === 0) return;
if (isPlaying || playState === 'playing' || playState === 'paused') {
// 简单处理:停止,然后以独奏方式播放
const wasPlaying = isPlaying;
let currentProgress = 0;
if (audioContext) {
if (wasPlaying) {
currentProgress = audioContext.currentTime - startTime;
} else {
currentProgress = pausedAt;
}
}
stopAllSources();
// 如果独奏按钮被激活 (class包含solo) 则只播放该轨, 否则全部播放
const isSolo = this.classList.contains('solo');
// 重新播放
if (audioBuffers.length > 0) {
// 播放所有轨道,但是增益控制:独奏轨增益1,其他0
const gains = audioBuffers.map((_, i) => {
if (isSolo) {
return i === idx ? 1 : 0;
} else {
return 1; // 取消独奏,全部播放
}
});
// 如果所有轨道都被独奏? 只播放当前轨道
playAllTracks(gains, currentProgress);
}
} else {
// 未播放,仅UI反馈
}
});
});
}
// 播放所有音轨 (支持增益数组)
function playAllTracks(gainArray, seekTime = 0) {
if (!audioContext || audioContext.state === 'closed') {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
if (audioContext.state === 'suspended') {
audioContext.resume();
}
// 停止之前的播放
stopAllSources();
if (audioBuffers.length === 0) return;
const currentTime = audioContext.currentTime;
// 如果 seekTime 大于0, 从指定位置开始
const offset = Math.max(0, seekTime);
// 计算每个音轨的实际起始时间 (考虑偏移)
// 偏移可以为负,但起始时间不能小于0,使用 clamp
const baseStart = currentTime;
// 保存 source 和 gain 节点
const sources = [];
const gains = [];
for (let i = 0; i < audioBuffers.length; i++) {
const buffer = audioBuffers[i];
if (!buffer) continue;
const gainVal = (gainArray && gainArray[i] !== undefined) ? gainArray[i] : 1;
if (gainVal === 0) continue; // 跳过静音轨
const offsetSec = trackOffsets[i] || 0;
// 实际播放起始位置: 从 offset 开始播放
let startOffset = Math.max(0, offsetSec); // 如果偏移为正, 从偏移位置开始
let delay = 0;
if (offsetSec < 0) {
// 负偏移: 延迟播放
delay = Math.abs(offsetSec);
startOffset = 0;