```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;

              

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐