<!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>
index.html
index.html