这个可以实现,多音轨合并混流音频,可拖拽时间

完整代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HTML5 音频编辑器</title>
<style>
body { font-family: sans-serif; margin: 20px; background-color: #f4f4f4; color: #333; }
.controls button { margin: 5px; padding: 10px 15px; background-color: #007bff; color: white; border: none; cursor: pointer; border-radius: 4px; }
.controls button:hover { background-color: #0056b3; }
.tracks-container {
margin-top: 20px;
border: 1px solid #ccc;
padding: 10px;
background-color: #fff;
position: relative; /* For absolute positioning of ruler */
overflow-x: auto; /* Allow horizontal scrolling for long timelines */
}
.timeline-ruler {
height: 20px;
background-color: #e0e0e0;
position: sticky; /* Make ruler sticky */
top: 0;
z-index: 10;
display: flex;
}
.ruler-mark {
min-width: 50px; /* Corresponds to 1 second if pixelsPerSecond is 50 */
border-left: 1px solid #aaa;
font-size: 10px;
text-align: right;
padding-right: 2px;
box-sizing: border-box;
}
.track {
border: 1px dashed #ddd;
margin-bottom: 10px;
min-height: 60px; /* Min height for dropping files */
position: relative; /* Crucial for absolute positioning of clips */
background-color: #f9f9f9;
padding: 5px 0; /* Add some padding for clips */
}
.track-header {
font-size: 0.9em;
color: #555;
padding: 2px 5px;
background-color: #eee;
display: flex;
justify-content: space-between;
align-items: center;
}
.track-header input[type="file"] { display: none; }
.audio-clip {
position: absolute;
height: 50px;
background-color: lightcoral;
border: 1px solid darkred;
border-radius: 3px;
cursor: move;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8em;
color: white;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
box-sizing: border-box;
padding: 0 5px;
}
.drop-zone-active {
border: 2px dashed dodgerblue !important;
background-color: #e6f7ff !important;
}
#loading-indicator {
display: none;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
padding: 20px;
background: rgba(0,0,0,0.7);
color: white;
border-radius: 5px;
z-index: 1000;
}
</style>
</head>
<body>
<h1>简易音频混音器</h1>
<div class="controls">
<button id="addTrackBtn">添加音轨</button>
<button id="mergeAndDownloadBtn">合并并下载 (WAV)</button>
</div>
<div class="tracks-container">
<div class="timeline-ruler" id="timelineRuler">
<!-- Ruler marks will be generated by JS -->
</div>
<div id="tracks">
<!-- Audio tracks will be added here -->
</div>
</div>
<div id="loading-indicator">处理中,请稍候...</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
const addTrackBtn = document.getElementById('addTrackBtn');
const mergeAndDownloadBtn = document.getElementById('mergeAndDownloadBtn');
const tracksContainer = document.getElementById('tracks');
const timelineRuler = document.getElementById('timelineRuler');
const loadingIndicator = document.getElementById('loading-indicator');
let audioContext;
let tracksData = []; // Array to store data for each track and its clips
let trackIdCounter = 0;
let clipIdCounter = 0;
const PIXELS_PER_SECOND = 50; // 50 pixels represent 1 second
const MAX_TIMELINE_SECONDS = 180; // Max length of timeline ruler
// Initialize AudioContext
function initAudioContext() {
if (!audioContext) {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
if (!audioContext) {
alert("浏览器不支持 Web Audio API");
return false;
}
return true;
}
// Generate timeline ruler
function generateTimelineRuler() {
timelineRuler.innerHTML = ''; // Clear existing marks
for (let i = 0; i < MAX_TIMELINE_SECONDS; i++) {
const mark = document.createElement('div');
mark.classList.add('ruler-mark');
mark.style.minWidth = `${PIXELS_PER_SECOND}px`;
mark.textContent = `${i}s`;
timelineRuler.appendChild(mark);
}
// Ensure the tracks container is wide enough
tracksContainer.style.minWidth = `${MAX_TIMELINE_SECONDS * PIXELS_PER_SECOND}px`;
}
// Add a new track
addTrackBtn.addEventListener('click', () => {
if (!initAudioContext()) return;
createTrackElement();
});
function createTrackElement() {
const trackId = `track-${trackIdCounter++}`;
const trackDiv = document.createElement('div');
trackDiv.classList.add('track');
trackDiv.id = trackId;
const trackHeader = document.createElement('div');
trackHeader.classList.add('track-header');
trackHeader.innerHTML = `
<span>音轨 ${trackIdCounter}</span>
<button class="add-audio-btn">添加音频</button>
<input type="file" accept="audio/*" multiple style="display:none;">
`;
trackDiv.appendChild(trackHeader);
tracksContainer.appendChild(trackDiv);
tracksData.push({ id: trackId, clips: [] });
// File input handling
const fileInput = trackHeader.querySelector('input[type="file"]');
const addAudioButton = trackHeader.querySelector('.add-audio-btn');
addAudioButton.onclick = () => fileInput.click();
fileInput.onchange = (e) => handleFiles(e.target.files, trackId);
// Drag and drop files onto track
trackDiv.addEventListener('dragover', (e) => {
e.preventDefault();
trackDiv.classList.add('drop-zone-active');
});
trackDiv.addEventListener('dragleave', (e) => {
trackDiv.classList.remove('drop-zone-active');
});
trackDiv.addEventListener('drop', (e) => {
e.preventDefault();
trackDiv.classList.remove('drop-zone-active');
if (e.dataTransfer.files.length > 0) {
handleFiles(e.dataTransfer.files, trackId);
} else {
// This is for dragging existing clips
handleClipDrop(e, trackId);
}
});
}
async function handleFiles(files, trackId) {
if (!initAudioContext()) return;
showLoading(true);
for (const file of files) {
if (file.type.startsWith('audio/')) {
try {
const arrayBuffer = await file.arrayBuffer();
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
addClipToTrack(file.name, audioBuffer, trackId);
} catch (error) {
console.error("Error decoding audio data:", error);
alert(`无法解码音频文件: ${file.name}`);
}
} else {
alert(`文件 ${file.name} 不是支持的音频格式。`);
}
}
showLoading(false);
}
function addClipToTrack(fileName, audioBuffer, trackId, startTime = 0) {
const trackData = tracksData.find(t => t.id === trackId);
if (!trackData) return;
const clipId = `clip-${clipIdCounter++}`;
const duration = audioBuffer.duration;
const clipData = {
id: clipId,
name: fileName,
buffer: audioBuffer,
startTime: startTime, // in seconds
duration: duration, // in seconds
trackId: trackId
};
trackData.clips.push(clipData);
renderClip(clipData);
}
function renderClip(clipData) {
const trackElement = document.getElementById(clipData.trackId);
if (!trackElement) return;
let clipElement = document.getElementById(clipData.id);
if (!clipElement) {
clipElement = document.createElement('div');
clipElement.classList.add('audio-clip');
clipElement.id = clipData.id;
clipElement.draggable = true;
clipElement.textContent = clipData.name.length > 20 ? clipData.name.substring(0,17) + '...' : clipData.name;
clipElement.title = clipData.name;
trackElement.appendChild(clipElement);
clipElement.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('text/plain', clipData.id);
e.dataTransfer.effectAllowed = 'move';
// Store offset of mouse click relative to clip's left edge
const rect = clipElement.getBoundingClientRect();
e.dataTransfer.setData('offsetX', (e.clientX - rect.left).toString());
});
}
clipElement.style.left = `${clipData.startTime * PIXELS_PER_SECOND}px`;
clipElement.style.width = `${clipData.duration * PIXELS_PER_SECOND}px`;
}
function handleClipDrop(e, targetTrackId) {
e.preventDefault();
const clipId = e.dataTransfer.getData('text/plain');
const offsetX = parseFloat(e.dataTransfer.getData('offsetX')) || 0;
const clipData = findClipById(clipId);
const targetTrackElement = document.getElementById(targetTrackId);
if (!clipData || !targetTrackElement) return;
// Calculate new startTime based on drop position relative to the track
const trackRect = targetTrackElement.getBoundingClientRect();
const dropXInTrack = e.clientX - trackRect.left - offsetX; // Adjust for mouse click offset within clip
let newStartTime = Math.max(0, dropXInTrack / PIXELS_PER_SECOND); // Ensure not negative
// Update data
// If changing tracks, remove from old trackData and add to new
if (clipData.trackId !== targetTrackId) {
const oldTrackData = tracksData.find(t => t.id === clipData.trackId);
if (oldTrackData) {
oldTrackData.clips = oldTrackData.clips.filter(c => c.id !== clipId);
}
const newTrackData = tracksData.find(t => t.id === targetTrackId);
if (newTrackData) {
clipData.trackId = targetTrackId;
newTrackData.clips.push(clipData);
}
}
clipData.startTime = newStartTime;
// Re-render (or simply move the DOM element if staying in same track visual parent)
const clipElement = document.getElementById(clipId);
if (clipElement.parentElement.id !== targetTrackId) {
targetTrackElement.appendChild(clipElement); // Move DOM element to new track
}
renderClip(clipData); // Update position and potentially parent
}
function findClipById(clipId) {
for (const track of tracksData) {
const clip = track.clips.find(c => c.id === clipId);
if (clip) return clip;
}
return null;
}
// Merge and Download
mergeAndDownloadBtn.addEventListener('click', async () => {
if (!initAudioContext()) return;
if (tracksData.every(track => track.clips.length === 0)) {
alert("请先添加一些音频片段到音轨。");
return;
}
showLoading(true);
let totalDuration = 0;
tracksData.forEach(track => {
track.clips.forEach(clip => {
totalDuration = Math.max(totalDuration, clip.startTime + clip.duration);
});
});
if (totalDuration === 0) {
alert("没有可合并的音频内容 (总时长为0)。");
showLoading(false);
return;
}
const offlineCtx = new OfflineAudioContext(
audioContext.destination.channelCount, // Use same channel count as main context
audioContext.sampleRate * totalDuration,
audioContext.sampleRate
);
tracksData.forEach(track => {
track.clips.forEach(clip => {
const source = offlineCtx.createBufferSource();
source.buffer = clip.buffer;
source.connect(offlineCtx.destination);
source.start(clip.startTime); // Start at its specific time
});
});
try {
const renderedBuffer = await offlineCtx.startRendering();
const wavBlob = audioBufferToWav(renderedBuffer);
downloadBlob(wavBlob, 'mixed_audio.wav');
} catch (error) {
console.error("Error rendering audio:", error);
alert("合并音频时发生错误: " + error.message);
} finally {
showLoading(false);
}
});
function showLoading(show) {
loadingIndicator.style.display = show ? 'block' : 'none';
}
// --- WAV Encoding Helper ---
// (This is a simplified WAV encoder. For production, a robust library might be better)
function audioBufferToWav(buffer) {
let numOfChan = buffer.numberOfChannels,
btwLength = buffer.length * numOfChan * 2 + 44, // 2 bytes per sample (16-bit)
btwArrBuff = new ArrayBuffer(btwLength),
btwView = new DataView(btwArrBuff),
btwChnls = [],
btwIndex,
btwSample,
btwOffset = 0,
btwPos = 0;
setUint32(0x46464952); // "RIFF"
setUint32(btwLength - 8); // file length - 8
setUint32(0x45564157); // "WAVE"
setUint32(0x20746d66); // "fmt " chunk
setUint32(16); // length = 16
setUint16(1); // PCM (uncompressed)
setUint16(numOfChan);
setUint32(buffer.sampleRate);
setUint32(buffer.sampleRate * 2 * numOfChan); // avg. bytes/sec
setUint16(numOfChan * 2); // block-align
setUint16(16); // 16-bit (hardcoded)
setUint32(0x61746164); // "data" - chunk
setUint32(buffer.length * numOfChan * 2); // data length
for (btwIndex = 0; btwIndex < buffer.numberOfChannels; btwIndex++)
btwChnls.push(buffer.getChannelData(btwIndex));
while (btwPos < buffer.length) {
for (btwIndex = 0; btwIndex < numOfChan; btwIndex++) {
// Muting negative values and clamping any > 1.0.
// Most audio sources are -1 to 1, but some decoders might output 0 to 1.
// For 16-bit WAV, we want to scale to -32768 to 32767.
btwSample = Math.max(-1, Math.min(1, btwChnls[btwIndex][btwPos]));
btwSample = btwSample < 0 ? btwSample * 0x8000 : btwSample * 0x7FFF; // Scale to 16-bit signed int
btwView.setInt16(btwOffset, btwSample, true); // Little-endian
btwOffset += 2;
}
btwPos++;
}
return new Blob([btwView], { type: 'audio/wav' });
function setUint16(data) {
btwView.setUint16(btwOffset, data, true);
btwOffset += 2;
}
function setUint32(data) {
btwView.setUint32(btwOffset, data, true);
btwOffset += 4;
}
}
function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// Initial setup
initAudioContext();
generateTimelineRuler();
createTrackElement(); // Create one track by default
});
</script>
</body>
</html> 网友回复


