未命名 YvgMFLedit icon

创建者:
邓朝元
Fork(复制)
下载
嵌入
BUG反馈
index.html
index.html
            
            <!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
    <title>Nexus Audio Studio | Pro Web DAW</title>
    <!-- 引入 Tone.js 用于高级 DSP 处理 (独立变调/EQ/离线渲染) -->
    <script src="https://cdnjs.cloudflare.com/ajax/libs/tone/14.8.49/Tone.js"></script>
    <!-- 引入 FontAwesome 图标 -->
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
    <style>
        :root {
            --bg-darkest: #0d0d0f;
            --bg-dark: #18181b;
            --bg-panel: #222226;
            --bg-hover: #2d2d32;
            --border: #333338;
            --accent: #00e5ff;
            --accent-glow: rgba(0, 229, 255, 0.4);
            --text-main: #e4e4e7;
            --text-dim: #a1a1aa;
            --danger: #ef4444;
            --success: #10b981;
            --wave-color: #00e5ff;
            --wave-bg: #111114;
            --selection: rgba(0, 229, 255, 0.2);
        }

        * { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
        
        body {
            font-family: 'Inter', system-ui, -apple-system, sans-serif;
            background: var(--bg-darkest);
            color: var(--text-main);
            height: 100vh;
            overflow: hidden;
            display: flex;
            flex-direction: column;
            user-select: none;
        }

        /* --- 顶部导航与全局走带 --- */
        header {
            height: 60px;
            background: var(--bg-dark);
            border-bottom: 1px solid var(--border);
            display: flex;
            align-items: center;
            padding: 0 20px;
            gap: 20px;
            z-index: 100;
            flex-shrink: 0;
        }

        .logo {
            font-weight: 800;
            font-size: 18px;
            letter-spacing: 1px;
            color: var(--accent);
            display: flex;
            align-items: center;
            gap: 8px;
        }

        .transport {
            display: flex;
            gap: 10px;
            background: var(--bg-panel);
            padding: 6px 12px;
            border-radius: 30px;
            border: 1px solid var(--border);
        }

        .btn {
            background: transparent;
            border: none;
            color: var(--text-main);
            width: 36px;
            height: 36px;
            border-radius: 50%;
            cursor: pointer;
            display: flex;
            align-items: center;
            justify-content: center;
            font-size: 14px;
            transition: all 0.2s;
        }
        .btn:hover { background: var(--bg-hover); color: var(--accent); }
        .btn:active { transform: scale(0.95); }
        .btn.primary { background: var(--accent); color: black; }
        .btn.primary:hover { background: #33ebff; box-shadow: 0 0 15px var(--accent-glow); }
        .btn.active { background: var(--accent); color: black; }
        .btn.danger { color: var(--danger); }

        .master-vol {
            margin-left: auto;
            display: flex;
            align-items: center;
            gap: 10px;
            font-size: 12px;
            color: var(--text-dim);
        }

        /* --- 主工作区布局 --- */
        .workspace {
            flex: 1;
            display: grid;
            grid-template-columns: 280px 1fr 300px;
            grid-template-rows: 1fr;
            overflow: hidden;
        }

        /* 侧边栏:轨道列表 */
        .sidebar {
            background: var(--bg-dark);
            border-right: 1px solid var(--border);
            display: flex;
            flex-direction: column;
            overflow-y: auto;
        }

        .panel-header {
            padding: 15px;
            font-size: 12px;
            font-weight: 700;
            text-transform: uppercase;
            color: var(--text-dim);
            border-bottom: 1px solid var(--border);
            display: flex;
            justify-content: space-between;
            align-items: center;
            position: sticky;
            top: 0;
            background: var(--bg-dark);
            z-index: 10;
        }

        .track-list { flex: 1; padding: 10px; display: flex; flex-direction: column; gap: 8px; }

        .track-item {
            background: var(--bg-panel);
            border: 1px solid var(--border);
            border-radius: 8px;
            padding: 12px;
            cursor: pointer;
            transition: 0.2s;
            position: relative;
        }
        .track-item.selected { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); background: rgba(0, 229, 255, 0.05); }
        .track-item:hover:not(.selected) { border-color: #555; }
        
        .track-title { font-weight: 600; font-size: 14px; margin-bottom: 8px; display: flex; justify-content: space-between; }
        .track-controls { display: flex; gap: 10px; font-size: 12px; color: var(--text-dim); }
        .track-btn { background: none; border: none; color: inherit; cursor: pointer; padding: 4px; }
        .track-btn.active { color: var(--accent); }
        .track-btn.mute.active { color: var(--danger); }

        /* 中间:编辑器 */
        .editor {
            background: var(--bg-darkest);
            display: flex;
            flex-direction: column;
            position: relative;
            overflow: hidden;
        }

        .toolbar {
            height: 50px;
            background: var(--bg-dark);
            border-bottom: 1px solid var(--border);
            display: flex;
            align-items: center;
            padding: 0 15px;
            gap: 15px;
            overflow-x: auto;
            flex-shrink: 0;
        }

        .tool-group { display: flex; gap: 5px; align-items: center; }
        .divider { width: 1px; height: 24px; background: var(--border); margin: 0 5px; }

        .canvas-container {
            flex: 1;
            position: relative;
            background: var(--wave-bg);
            overflow: hidden;
            cursor: crosshair;
        }

        canvas { display: block; width: 100%; height: 100%; }
        
        .playhead {
            position: absolute;
            top: 0;
            bottom: 0;
            width: 2px;
            background: var(--danger);
            box-shadow: 0 0 8px var(--danger);
            pointer-events: none;
            left: 0;
            z-index: 20;
            display: none;
        }

        .zoom-controls {
            position: absolute;
            bottom: 20px;
            right: 20px;
            background: rgba(24, 24, 27, 0.9);
            backdrop-filter: blur(10px);
            border: 1px solid var(--border);
            border-radius: 30px;
            padding: 5px;
            display: flex;
            gap: 5px;
            z-index: 30;
        }

        /* 右侧:检查器 (Inspector) */
        .inspector {
            background: var(--bg-dark);
            border-left: 1px solid var(--border);
            overflow-y: auto;
            padding: 20px;
        }

        .inspector-section {
            margin-bottom: 25px;
        }
        .inspector-title {
            font-size: 11px;
            font-weight: 700;
            text-transform: uppercase;
            color: var(--accent);
            margin-bottom: 15px;
            letter-spacing: 1px;
        }

        .param-row {
            margin-bottom: 15px;
        }
        .param-label {
            display: flex;
            justify-content: space-between;
            font-size: 12px;
            color: var(--text-dim);
            margin-bottom: 6px;
        }
        .param-value { color: var(--text-main); font-weight: 600; font-family: monospace; }

        /* 自定义滑块 */
        input[type="range"] {
            -webkit-appearance: none;
            width: 100%;
            height: 4px;
            background: var(--border);
            border-radius: 2px;
            outline: none;
        }
        input[type="range"]::-webkit-slider-thumb {
            -webkit-appearance: none;
            width: 14px;
            height: 14px;
            background: var(--accent);
            border-radius: 50%;
            cursor: pointer;
            box-shadow: 0 0 5px var(--accent-glow);
        }
        input[type="range"]::-moz-range-thumb {
            width: 14px;
            height: 14px;
            background: var(--accent);
            border-radius: 50%;
            cursor: pointer;
            border: none;
        }

        /* 空状态 */
        .empty-state {
            flex: 1;
            display: flex;
            flex-direction: column;
            align-items: center;
            justify-content: center;
            color: var(--text-dim);
            gap: 15px;
        }
        .drop-zone {
            border: 2px dashed var(--border);
            padding: 40px;
            border-radius: 12px;
            text-align: center;
            transition: 0.2s;
            width: 80%;
            max-width: 400px;
        }
        .drop-zone.dragover { border-color: var(--accent); background: rgba(0, 229, 255, 0.05); }

        /* 移动端适配 */
        @media (max-width: 1024px) {
            .workspace {
                grid-template-columns: 1fr;
                grid-template-rows: auto 1fr auto;
                grid-template-areas: 
                    "sidebar"
                    "editor"
                    "inspector";
            }
            .sidebar {
                grid-area: sidebar;
                max-height: 30vh;
                border-right: none;
                border-bottom: 1px solid var(--border);
            }
            .editor { grid-area: editor; min-height: 40vh; }
            .inspector {
                grid-area: inspector;
                border-left: none;
                border-top: 1px solid var(--border);
                max-height: 40vh;
            }
            header { padding: 0 10px; gap: 10px; }
            .master-vol { display: none; } /* 移动端隐藏主控,放入侧边栏或 inspector */
            .logo span { display: none; }
        }

        /* 滚动条美化 */
        ::-webkit-scrollbar { width: 8px; height: 8px; }
        ::-webkit-scrollbar-track { background: var(--bg-darkest); }
        ::-webkit-scrollbar-thumb { background: #333; border-radius: 4px; }
        ::-webkit-scrollbar-thumb:hover { background: #555; }

        /* 弹窗/Toast */
        .toast {
            position: fixed;
            bottom: 20px;
            left: 50%;
            transform: translateX(-50%) translateY(100px);
            background: var(--bg-panel);
            border: 1px solid var(--accent);
            color: var(--text-main);
            padding: 12px 24px;
            border-radius: 8px;
            box-shadow: 0 10px 25px rgba(0,0,0,0.5);
            transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
            z-index: 1000;
            pointer-events: none;
            font-size: 14px;
        }
        .toast.show { transform: translateX(-50%) translateY(0); }

    </style>
</head>
<body>

    <!-- 顶部导航 -->
    <header>
        <div class="logo">
            <i class="fas fa-wave-square"></i>
            <span>NEXUS STUDIO</span>
        </div>
        
        <div class="transport">
            <button class="btn" id="btnStop" title="停止"><i class="fas fa-stop"></i></button>
            <button class="btn primary" id="btnPlay" title="播放/暂停"><i class="fas fa-play"></i></button>
            <button class="btn" id="btnLoop" title="循环选区"><i class="fas fa-sync-alt"></i></button>
            <div class="divider"></div>
            <button class="btn" id="btnUndo" title="撤销"><i class="fas fa-undo"></i></button>
            <button class="btn" id="btnRedo" title="重做"><i class="fas fa-redo"></i></button>
        </div>

        <div class="master-vol">
            <span>MASTER</span>
            <input type="range" id="masterVol" min="-24" max="6" value="0" step="1" style="width: 100px;">
            <span id="masterVolVal">0dB</span>
        </div>
    </header>

    <!-- 主工作区 -->
    <div class="workspace">
        
        <!-- 左侧:轨道管理 -->
        <aside class="sidebar">
            <div class="panel-header">
                <span>Tracks</span>
                <button class="btn" id="btnAddTrack" title="添加音频" style="width:24px; height:24px; font-size:12px;"><i class="fas fa-plus"></i></button>
            </div>
            <div class="track-list" id="trackList">
                <!-- 动态生成 -->
            </div>
            <div style="padding: 15px; border-top: 1px solid var(--border);">
                <button class="btn primary" style="width: 100%; border-radius: 6px; height: 40px;" id="btnSynth">
                    <i class="fas fa-microchip"></i> 生成合成器测试音
                </button>
            </div>
        </aside>

        <!-- 中间:波形编辑器 -->
        <main class="editor">
            <div class="toolbar">
                <div class="tool-group">
                    <button class="btn" id="btnTrim" title="裁剪选区"><i class="fas fa-cut"></i></button>
                    <button class="btn" id="btnFadeIn" title="选区淡入"><i class="fas fa-sort-amount-up"></i></button>
                    <button class="btn" id="btnFadeOut" title="选区淡出"><i class="fas fa-sort-amount-down"></i></button>
                </div>
                <div class="divider"></div>
                <div class="tool-group">
                    <span style="font-size: 12px; color: var(--text-dim);">时间:</span>
                    <span id="timeDisplay" style="font-family: monospace; font-size: 14px;">00:00.00</span>
                </div>
                <div style="flex: 1;"></div>
                <button class="btn" id="btnExport" title="导出多轨 WAV" style="width: auto; padding: 0 15px; border-radius: 6px; font-size: 12px;">
                    <i class="fas fa-download"></i> 导出混音
                </button>
            </div>

            <div class="canvas-container" id="canvasContainer">
                <canvas id="waveCanvas"></canvas>
                <div class="playhead" id="playhead"></div>
                
                <div class="zoom-controls">
                    <button class="btn" id="btnZoomOut"><i class="fas fa-search-minus"></i></button>
                    <button class="btn" id="btnZoomIn"><i class="fas fa-search-plus"></i></button>
                    <button class="btn" id="btnZoomFit"><i class="fas fa-compress"></i></button>
                </div>

                <div class="empty-state" id="emptyState">
                    <div class="drop-zone" id="dropZone">
                        <i class="fas fa-cloud-upload-alt" style="font-size: 32px; margin-bottom: 10px; color: var(--accent);"></i>
                        <p>拖拽音频文件到此处</p>
                        <p style="font-size: 12px; margin-top: 5px;">或点击左侧 "+" 添加</p>
                    </div>
                </div>
            </div>
            <input type="file" id="fileInput" accept="audio/*" multiple style="display: none;">
        </main>

        <!-- 右侧:参数检查器 -->
        <aside class="inspector" id="inspector">
            <div class="empty-state" style="padding-top: 50px;" id="inspectorEmpty">
                <i class="fas fa-sliders-h" style="font-size: 24px; color: var(--text-dim);"></i>
                <p style="font-size: 14px;">选择一个轨道以编辑参数</p>
            </div>

            <div id="inspectorContent" style="display: none;">
                <div class="inspector-section">
                    <div class="inspector-title">Playback & Pitch</div>
                    <div class="param-row">
                        <div class="param-label"><span>Speed (Rate)</span><span class="param-value" id="valRate">1.00x</span></div>
                        <input type="range" id="ctrlRate" min="0.25" max="4.0" step="0.01" value="1.0">
                    </div>
                    <div class="param-row">
                        <div class="param-label"><span>Pitch (Semitones)</span><span class="param-value" id="valPitch">0 st</span></div>
                        <input type="range" id="ctrlPitch" min="-12" max="12" step="1" value="0">
                        <div style="font-size: 10px; color: var(--text-dim); margin-top: 4px;">* 独立变调算法 (CPU 密集)</div>
                    </div>
                </div>

                <div class="inspector-section">
                    <div class="inspector-title">Mix & EQ</div>
                    <div class="param-row">
                        <div class="param-label"><span>Volume</span><span class="param-value" id="valVol">0 dB</span></div>
                        <input type="range" id="ctrlVol" min="-24" max="6" step="0.1" value="0">
                    </div>
                    <div class="param-row">
                        <div class="param-label"><span>Pan (L/R)</span><span class="param-value" id="valPan">C</span></div>
                        <input type="range" id="ctrlPan" min="-1" max="1" step="0.01" value="0">
                    </div>
                    <div class="param-row">
                        <div class="param-label"><span>EQ Low</span><span class="param-value" id="valEQL">0 dB</span></div>
                        <input type="range" id="ctrlEQL" min="-12" max="12" step="1" value="0">
                    </div>
                    <div class="param-row">
                        <div class="param-label"><span>EQ Mid</span><span class="param-value" id="valEQM">0 dB</span></div>
                        <input type="range" id="ctrlEQM" min="-12" max="12" step="1" value="0">
                    </div>
                    <div class="param-row">
                        <div class="param-label"><span>EQ High</span><span class="param-value" id="valEQH">0 dB</span></div>
                        <input type="range" id="ctrlEQH" min="-12" max="12" step="1" value="0">
                    </div>
                </div>
            </div>
        </aside>
    </div>

    <div class="toast" id="toast"></div>

<script>
/**
 * NEXUS AUDIO STUDIO ENGINE
 * 核心架构基于 Tone.js
 */

class AudioEngine {
    constructor() {
        this.tracks = [];
        this.selectedTrackId = null;
        this.historyStack = []; // 用于 Undo
        this.redoStack = [];
        this.isLooping = false;
        
        // Master Chain
        this.masterVol = new Tone.Volume(0).toDestination();
        this.masterAnalyser = new Tone.Analyser('waveform', 256);
        this.masterVol.connect(this.masterAnalyser);

        // UI Elements
        this.canvas = document.getElementById('waveCanvas');
        this.ctx = this.canvas.getContext('2d');
        this.playhead = document.getElementById('playhead');
        
        this.zoom = 1.0;
        this.scrollOffset = 0; // 0 to 1
        this.selection = { start: null, end: null, isDragging: false };
        
        this.initEvents();
        this.resizeCanvas();
        window.addEventListener('resize', () => this.resizeCanvas());
    }

    async init() {
        await Tone.start();
        console.log("Audio Context Ready");
    }

    async addTrack(file) {
        const url = URL.createObjectURL(file);
        const player = new Tone.Player(url);
        await Tone.loaded();
        
        const track = new Track(file.name, player);
        track.player.connect(track.pitchShift);
        track.pitchShift.connect(track.eq);
        track.eq.connect(track.volume);
        track.volume.connect(track.panner);
        track.panner.connect(this.masterVol);
        
        track.player.sync();
        this.tracks.push(track);
        this.selectTrack(track.id);
        UI.renderTrackList();
        this.drawWaveform();
        UI.showToast(`已加载: ${file.name}`);
    }

    addSynthTrack() {
        // 生成一个测试 Buffer
        const buffer = new Tone.ToneAudioBuffer();
        const sr = Tone.getContext().sampleRate;
        const len = sr * 4; // 4 seconds
        const arr = new Float32Array(len);
        for(let i=0; i<len; i++) {
            const t = i / sr;
            // 复杂的 FM 合成音色
            const freq = 110 * (1 + Math.sin(t * 2)); 
            arr[i] = 0.3 * Math.sin(2 * Math.PI * freq * t + 2 * Math.sin(2 * Math.PI * 5 * t));
        }
        buffer.fromArray([arr, arr]); // Stereo
        
        const player = new Tone.Player(buffer);
        const track = new Track("FM Synth Test", player);
        
        track.player.connect(track.pitchShift);
        track.pitchShift.connect(track.eq);
        track.eq.connect(track.volume);
        track.volume.connect(track.panner);
        track.panner.connect(this.masterVol);
        
        track.player.sync();
        this.tracks.push(track);
        this.selectTrack(track.id);
        UI.renderTrackList();
        this.drawWaveform();
    }

    selectTrack(id) {
        this.selectedTrackId = id;
        UI.renderTrackList();
        UI.updateInspector();
        this.drawWaveform();
    }

    getSelectedTrack() {
        return this.tracks.find(t => t.id === this.selectedTrackId);
    }

    play() {
        if (this.tracks.length === 0) return;
        
        // 处理循环逻辑
        if (this.isLooping && this.selection.start !== null && this.selection.end !== null) {
            const track = this.getSelectedTrack();
            if (track) {
                const startSec = this.selection.start * track.player.buffer.duration;
                const endSec = this.selection.end * track.player.buffer.duration;
                
                // Tone.Transport 循环设置
                Tone.Transport.setLoopPoints(startSec, endSec);
                Tone.Transport.loop = true;
            }
        } else {
            Tone.Transport.loop = false;
        }

        Tone.Transport.start();
        UI.updateTransport(true);
        this.animatePlayhead();
    }

    stop() {
        Tone.Transport.stop();
        Tone.Transport.position = 0;
        UI.updateTransport(false);
        this.playhead.style.display = 'none';
    }

    pause() {
        Tone.Transport.pause();
        UI.updateTransport(false);
    }

    animatePlayhead() {
        if (Tone.Transport.state !== 'started') return;
        
        const track = this.getSelectedTrack();
        if (!track || !track.player.buffer) return;

        const duration = track.player.buffer.duration;
        const currentTime = Tone.Transport.seconds;
        
        // 计算在可视区域内的位置
        const visibleDuration = duration / this.zoom;
        const startTime = this.scrollOffset * duration;
        const endTime = startTime + visibleDuration;
        
        let xPercent = -1;
        if (currentTime >= startTime && currentTime <= endTime) {
            xPercent = (currentTime - startTime) / visibleDuration;
        }

        if (xPercent >= 0 && xPercent <= 1) {
            this.playhead.style.display = 'block';
            this.playhead.style.left = `${xPercent * 100}%`;
        } else {
            this.playhead.style.display = 'none';
        }

        // 更新时间显示
        document.getElementById('timeDisplay').textContent = this.formatTime(currentTime);

        requestAnimationFrame(() => this.animatePlayhead());
    }

    formatTime(sec) {
        const m = Math.floor(sec / 60).toString().padStart(2, '0');
        const s = Math.floor(sec % 60).toString().padStart(2, '0');
        const ms = Math.floor((sec % 1) * 100).toString().padStart(2, '0');
        return `${m}:${s}.${ms}`;
    }

    resizeCanvas() {
        const rect = this.canvas.parentElement.getBoundingClientRect();
        const dpr = window.devicePixelRatio || 1;
        this.canvas.width = rect.width * dpr;
        this.canvas.height = rect.height * dpr;
        this.ctx.scale(dpr, dpr);
        this.canvas.style.width = `${rect.width}px`;
        this.canvas.style.height = `${rect.height}px`;
        this.width = rect.width;
        this.height = rect.height;
        this.drawWaveform();
    }

    drawWaveform() {
        const track = this.getSelectedTrack();
        this.ctx.fillStyle = '#111114';
        this.ctx.fillRect(0, 0, this.width, this.height);

        if (!track || !track.player.buffer) return;

        const data = track.player.buffer.getChannelData(0);
        const totalSamples = data.length;
        
        // 计算可视范围
        const samplesPerPixel = Math.floor(totalSamples / (this.width * this.zoom));
        const startSample = Math.floor(this.scrollOffset * totalSamples);
        
        // 绘制选区背景
        if (this.selection.start !== null && this.selection.end !== null) {
            const x1 = Math.min(this.selection.start, this.selection.end) * this.width;
            const x2 = Math.max(this.selection.start, this.selection.end) * this.width;
            this.ctx.fillStyle = 'rgba(0, 229, 255, 0.15)';
            this.ctx.fillRect(x1, 0, x2 - x1, this.height);
        }

        // 绘制波形
        this.ctx.beginPath();
        this.ctx.strokeStyle = '#00e5ff';
        this.ctx.lineWidth = 1.5;
        this.ctx.moveTo(0, this.height / 2);

        const amp = this.height / 2;
        for (let i = 0; i < this.width; i++) {
            let min = 1.0, max = -1.0;
            const startIdx = startSample + (i * samplesPerPixel);
            
            // 降采样优化
            for (let j = 0; j < samplesPerPixel; j++) {
                const idx = startIdx + j;
                if (idx >= totalSamples) break;
                const val = data[idx];
                if (val < min) min = val;
                if (val > max) max = val;
            }
            
            const yMin = (1 + min) * amp;
            const yMax = (1 + max) * amp;
            
            this.ctx.moveTo(i, yMin);
            this.ctx.lineTo(i, yMax);
        }
        this.ctx.stroke();
        
        // 绘制中线
        this.ctx.strokeStyle = '#333';
        this.ctx.lineWidth = 1;
        this.ctx.beginPath();
        this.ctx.moveTo(0, amp);
        this.ctx.lineTo(this.width, amp);
        this.ctx.stroke();
    }

    // 编辑操作 (带历史记录)
    saveState() {
        const track = this.getSelectedTrack();
        if (!track) return;
        // 简化版:仅保存 buffer 数据用于撤销
        this.historyStack.push({
            trackId: track.id,
            buffer: track.player.buffer.toArray()
        });
        if (this.historyStack.length > 10) this.historyStack.shift();
        this.redoStack = [];
    }

    trimSelection() {
        const track = this.getSelectedTrack();
        if (!track || this.selection.start === null) {
            UI.showToast("请先拖拽选择区域", true);
            return;
        }

        this.saveState();

        const startPct = Math.min(this.selection.start, this.selection.end);
        const endPct = Math.max(this.selection.start, this.selection.end);
        
        const oldBuffer = track.player.buffer;
        const sr = oldBuffer.sampleRate;
        const startSample = Math.floor(startPct * oldBuffer.length);
        const endSample = Math.floor(endPct * oldBuffer.length);
        const newLength = endSample - startSample;

        const newArr = new Float32Array(newLength);
        const oldArr = oldBuffer.getChannelData(0);
        for (let i = 0; i < newLength; i++) {
            newArr[i] = oldArr[startSample + i];
        }

        const newBuffer = new Tone.ToneAudioBuffer();
        newBuffer.fromArray([newArr, newArr]); // 假设为单声道或双声道复制
        
        // 替换 Player 的 buffer
        track.player.buffer = newBuffer;
        this.selection = { start: null, end: null, isDragging: false };
        this.drawWaveform();
        UI.showToast("已裁剪选区");
    }

    applyFade(type) { // 'in' or 'out'
        const track = this.getSelectedTrack();
        if (!track) return;

        this.saveState();

        const startPct = this.selection.start !== null ? Math.min(this.selection.start, this.selection.end) : 0;
        const endPct = this.selection.start !== null ? Math.max(this.selection.start, this.selection.end) : 1;

        const arr = track.player.buffer.getChannelData(0);
        const startSample = Math.floor(startPct * arr.length);
        const endSample = Math.floor(endPct * arr.length);
        const len = endSample - startSample;

        for (let i = 0; i < len; i++) {
            const factor = type === 'in' ? (i / len) : (1 - (i / len));
            arr[startSample + i] *= factor;
        }
        
        // 更新 buffer
        track.player.buffer.fromArray([arr, arr]);
        this.drawWaveform();
        UI.showToast(`已应用淡${type === 'in' ? '入' : '出'}`);
    }

    undo() {
        if (this.historyStack.length === 0) return;
        const state = this.historyStack.pop();
        const track = this.tracks.find(t => t.id === state.trackId);
        if (track) {
            // 保存当前到 redo
            this.redoStack.push({
                trackId: track.id,
                buffer: track.player.buffer.toArray()
            });
            
            const newBuffer = new Tone.ToneAudioBuffer();
            newBuffer.fromArray(state.buffer);
            track.player.buffer = newBuffer;
            this.drawWaveform();
            UI.showToast("已撤销");
        }
    }

    async exportMix() {
        if (this.tracks.length === 0) return;
        UI.showToast("正在离线渲染混音...");

        // 寻找最长轨道的时长
        let maxDur = 0;
        this.tracks.forEach(t => {
            if(t.player.buffer) maxDur = Math.max(maxDur, t.player.buffer.duration);
        });

        try {
            const buffer = await Tone.Offline(({ transport }) => {
                // 重建图
                this.tracks.forEach(t => {
                    const p = new Tone.Player(t.player.buffer);
                    const ps = new Tone.PitchShift(t.pitchShift.pitch);
                    const eq = new Tone.EQ3(t.eq.low.value, t.eq.mid.value, t.eq.high.value);
                    const vol = new Tone.Volume(t.volume.volume.value);
                    const pan = new Tone.Panner(t.panner.pan.value);
                    
                    p.connect(ps);
                    ps.connect(eq);
                    eq.connect(vol);
                    vol.connect(pan);
                    pan.toDestination();
                    
                    p.playbackRate = t.player.playbackRate;
                    p.sync();
                    p.start(0);
                });
                
                transport.start();
            }, maxDur);

            this.downloadWav(buffer);
            UI.showToast("渲染完成并已下载");
        } catch (e) {
            console.error(e);
            UI.showToast("渲染失败", true);
        }
    }

    downloadWav(buffer) {
        const w = new Worker(
            URL.createObjectURL(
                new Blob([
                    `self.onmessage = e => {
                        const b = e.data;
                        const w = new WaveformAudio(b);
                        self.postMessage(w.getBlob());
                    };
                    class WaveformAudio {
                        constructor(buffer) { this.buffer = buffer; }
                        getBlob() {
                            const channels = this.buffer.numberOfChannels;
                            const length = this.buffer.length * channels * 2 + 44;
                            const buffer = new ArrayBuffer(length);
                            const view = new DataView(buffer);
                            // ... WAV encoding logic (simplified) ...
                            return new Blob([buffer], {type: 'audio/wav'});
                        }
                    }`
                ])
            )
        );
        
        // Fallback simple WAV encoder inline
        const numCh = buffer.numberOfChannels;
        const sr = buffer.sampleRate;
        const len = buffer.length;
        const out = new ArrayBuffer(44 + len * numCh * 2);
        const view = new DataView(out);

        const writeStr = (o, s) => { for(let i=0;i<s.length;i++) view.setUint8(o+i,s.charCodeAt(i)); };
        writeStr(0,'RIFF'); view.setUint32(4,36+len*numCh*2,true); writeStr(8,'WAVE');
        writeStr(12,'fmt '); view.setUint32(16,16,true); view.setUint16(20,1,true);
        view.setUint16(22,numCh,true); view.setUint32(24,sr,true);
        view.setUint32(28,sr*numCh*2,true); view.setUint16(32,numCh*2,true);
        view.setUint16(34,16,true); writeStr(36,'data'); view.setUint32(40,len*numCh*2,true);

        let offset = 44;
        for(let i=0;i<len;i++){
            for(let ch=0;ch<numCh;ch++){
                const s = Math.max(-1,Math.min(1,buffer.getChannelData(ch)[i]));
                view.setInt16(offset,s<0?s*0x8000:s*0x7FFF,true);
                offset+=2;
            }
        }
        
        const blob = new Blob([out], {type: 'audio/wav'});
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        a.download = 'nexus-mix.wav';
        a.click();
    }

    initEvents() {
        // Transport
        document.getElementById('btnPlay').onclick = () => {
            if (Tone.Transport.state === 'started') this.pause();
            else this.play();
        };
        document.getElementById('btnStop').onclick = () => this.stop();
        document.getElementById('btnLoop').onclick = (e) => {
            this.isLooping = !this.isLooping;
            e.currentTarget.classList.toggle('active', this.isLooping);
        };
        document.getElementById('btnUndo').onclick = () => this.undo();
        
        // Tools
        document.getElementById('btnTrim').onclick = () => this.trimSelection();
        document.getElementById('btnFadeIn').onclick = () => this.applyFade('in');
        document.getElementById('btnFadeOut').onclick = () => this.applyFade('out');
        document.getElementById('btnExport').onclick = () => this.exportMix();
        
        // Zoom
        document.getElementById('btnZoomIn').onclick = () => { this.zoom = Math.min(10, this.zoom * 1.5); this.drawWaveform(); };
        document.getElementById('btnZoomOut').onclick = () => { this.zoom = Math.max(1, this.zoom / 1.5); this.drawWaveform(); };
        document.getElementById('btnZoomFit').onclick = () => { this.zoom = 1; this.scrollOffset = 0; this.drawWaveform(); };

        // Canvas Interaction (Mouse & Touch)
        const container = document.getElementById('canvasContainer');
        
        const getPos = (e) => {
            const rect = this.canvas.getBoundingClientRect();
            const clientX = e.touches ? e.touches[0].clientX : e.clientX;
            return (clientX - rect.left) / rect.width;
        };

        const startDrag = (e) => {
            if (!this.getSelectedTrack()) return;
            e.preventDefault();
            this.selection.isDragging = true;
            this.selection.start = getPos(e);
            this.selection.end = this.selection.start;
        };

        const moveDrag = (e) => {
            if (!this.selection.isDragging) return;
            e.preventDefault();
            this.selection.end = getPos(e);
            this.drawWaveform();
        };

        const endDrag = () => {
            this.selection.isDragging = false;
        };

        this.canvas.addEventListener('mousedown', startDrag);
        this.canvas.addEventListener('touchstart', startDrag, {passive: false});
        
        window.addEventListener('mousemove', moveDrag);
        window.addEventListener('touchmove', moveDrag, {passive: false});
        
        window.addEventListener('mouseup', endDrag);
        window.addEventListener('touchend', endDrag);

        // Wheel Zoom & Pan
        this.canvas.addEventListener('wheel', (e) => {
            e.preventDefault();
            if (e.ctrlKey || e.metaKey) {
                // Zoom
                const factor = e.deltaY > 0 ? 0.9 : 1.1;
                this.zoom = Math.max(1, Math.min(20, this.zoom * factor));
            } else {
                // Pan
                const panAmount = e.deltaY * 0.001;
                this.scrollOffset = Math.max(0, Math.min(1 - (1/this.zoom), this.scrollOffset + panAmount));
            }
            this.drawWaveform();
        }, {passive: false});

        // File Drop
        const dropZone = document.getElementById('dropZone');
        ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
            dropZone.addEventListener(eventName, (e) => { e.preventDefault(); e.stopPropagation(); }, false);
        });
        ['dragenter', 'dragover'].forEach(eventName => {
            dropZone.addEventListener(eventName, () => dropZone.classList.add('dragover'), false);
        });
        ['dragleave', 'drop'].forEach(eventName => {
            dropZone.addEventListener(eventName, () => dropZone.classList.remove('dragover'), false);
        });
        dropZone.addEventListener('drop', (e) => {
            const files = e.dataTransfer.files;
            for(let f of files) this.addTrack(f);
        });

        document.getElementById('btnAddTrack').onclick = () => document.getElementById('fileInput').click();
        document.getElementById('fileInput').onchange = (e) => {
            for(let f of e.target.files) this.addTrack(f);
        };
        document.getElementById('btnSynth').onclick = () => this.addSynthTrack();

        // Master Vol
        document.getElementById('masterVol').oninput = (e) => {
            this.masterVol.volume.value = parseFloat(e.target.value);
            document.getElementById('masterVolVal').textContent = `${e.target.value}dB`;
        };
    }
}

class Track {
    constructor(name, player) {
        this.id = Math.random().toString(36).substr(2, 9);
        this.name = name;
        this.player = player;
        
        // Effects Chain
        this.pitchShift = new Tone.PitchShift(0);
        this.eq = new Tone.EQ3(0, 0, 0);
        this.volume = new Tone.Volume(0);
        this.panner = new Tone.Panner(0);
        
        this.isMuted = false;
        this.isSolo = false;
    }

    setMute(m) {
        this.isMuted = m;
        this.volume.mute = m;
    }
}

const UI = {
    engine: null,
    
    init(engine) {
        this.engine = engine;
        this.bindInspector();
    },

    renderTrackList() {
        const list = document.getElementById('trackList');
        const empty = document.getElementById('emptyState');
        
        if (this.engine.tracks.length > 0) {
            empty.style.display = 'none';
        } else {
            empty.style.display = 'flex';
        }

        list.innerHTML = '';
        this.engine.tracks.forEach(t => {
            const el = document.createElement('div');
            el.className = `track-item ${t.id === this.engine.selectedTrackId ? 'selected' : ''}`;
            el.onclick = () => this.engine.selectTrack(t.id);
            
            el.innerHTML = `
                <div class="track-title">
                    <span><i class="fas fa-wave-square" style="margin-right:5px; color:var(--accent);"></i>${t.name}</span>
                    <span style="font-size:10px; color:var(--text-dim);">${t.player.buffer ? t.player.buffer.duration.toFixed(1)+'s' : ''}</span>
                </div>
                <div class="track-controls">
                    <button class="track-btn mute ${t.isMuted ? 'active' : ''}" title="Mute">M</button>
                    <button class="track-btn" title="Solo">S</button>
                    <span style="flex:1"></span>
                    <button class="track-btn danger" title="Remove"><i class="fas fa-trash"></i></button>
                </div>
            `;
            
            // Bind quick actions
            el.querySelector('.mute').onclick = (e) => {
                e.stopPropagation();
                t.setMute(!t.isMuted);
                this.renderTrackList();
            };
            
            list.appendChild(el);
        });
    },

    updateInspector() {
        const track = this.engine.getSelectedTrack();
        const empty = document.getElementById('inspectorEmpty');
        const content = document.getElementById('inspectorContent');
        
        if (!track) {
            empty.style.display = 'flex';
            content.style.display = 'none';
            return;
        }
        
        empty.style.display = 'none';
        content.style.display = 'block';
        
        // Sync values
        document.getElementById('ctrlRate').value = track.player.playbackRate;
        document.getElementById('valRate').textContent = track.player.playbackRate.toFixed(2) + 'x';
        
        document.getElementById('ctrlPitch').value = track.pitchShift.pitch;
        document.getElementById('valPitch').textContent = track.pitchShift.pitch + ' st';
        
        document.getElementById('ctrlVol').value = track.volume.volume.value;
        document.getElementById('valVol').textContent = track.volume.volume.value + ' dB';
        
        document.getElementById('ctrlPan').value = track.panner.pan.value;
        document.getElementById('valPan').textContent = track.panner.pan.value === 0 ? 'C' : (track.panner.pan.value < 0 ? 'L' + Math.abs(track.panner.pan.value*100).toFixed(0) : 'R' + (track.panner.pan.value*100).toFixed(0));
        
        document.getElementById('ctrlEQL').value = track.eq.low.value;
        document.getElementById('valEQL').textContent = track.eq.low.value + ' dB';
        document.getElementById('ctrlEQM').value = track.eq.mid.value;
        document.getElementById('valEQM').textContent = track.eq.mid.value + ' dB';
        document.getElementById('ctrlEQH').value = track.eq.high.value;
        document.getElementById('valEQH').textContent = track.eq.high.value + ' dB';
    },

    bindInspector() {
        const bind = (id, valId, cb, suffix='') => {
            const el = document.getElementById(id);
            el.oninput = () => {
                const v = parseFloat(el.value);
                document.getElementById(valId).textContent = v + suffix;
                const t = this.engine.getSelectedTrack();
                if(t) cb(t, v);
            };
        };

        bind('ctrlRate', 'valRate', (t, v) => t.player.playbackRate = v, 'x');
        bind('ctrlPitch', 'valPitch', (t, v) => t.pitchShift.pitch = v, ' st');
        bind('ctrlVol', 'valVol', (t, v) => t.volume.volume.value = v, ' dB');
        bind('ctrlPan', 'valPan', (t, v) => {
            t.panner.pan.value = v;
            document.getElementById('valPan').textContent = v === 0 ? 'C' : (v < 0 ? 'L' + Math.abs(v*100).toFixed(0) : 'R' + (v*100).toFixed(0));
        });
        bind('ctrlEQL', 'valEQL', (t, v) => t.eq.low.value = v, ' dB');
        bind('ctrlEQM', 'valEQM', (t, v) => t.eq.mid.value = v, ' dB');
        bind('ctrlEQH', 'valEQH', (t, v) => t.eq.high.value = v, ' dB');
    },

    updateTransport(playing) {
        const btn = document.getElementById('btnPlay');
        btn.innerHTML = playing ? '<i class="fas fa-pause"></i>' : '<i class="fas fa-play"></i>';
    },

    showToast(msg, isError = false) {
        const t = document.getElementById('toast');
        t.textContent = msg;
        t.style.borderColor = isError ? 'var(--danger)' : 'var(--accent)';
        t.classList.add('show');
        setTimeout(() => t.classList.remove('show'), 2500);
    }
};

// Boot
const engine = new AudioEngine();
UI.init(engine);
engine.init();
</script>
</body>
</html>
        
编辑器加载中
预览
控制台