creadas dos versiones. la primera exporta solo a wav, la segunda, exporta igual al formato de entrada
This commit is contained in:
163
index.html
Normal file
163
index.html
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Audio Cutter Pro 2026</title>
|
||||||
|
<script src="wavesurfer.min.js"></script>
|
||||||
|
<script src="plugins/regions.min.js"></script>
|
||||||
|
<style>
|
||||||
|
:root { --bg: #0f172a; --card: #1e293b; --accent: #3b82f6; }
|
||||||
|
body { font-family: sans-serif; background: var(--bg); color: white; display: flex; flex-direction: column; align-items: center; padding: 20px; }
|
||||||
|
.card { background: var(--card); padding: 20px; border-radius: 15px; width: 100%; max-width: 700px; box-shadow: 0 10px 30px rgba(0,0,0,0.5); }
|
||||||
|
#waveform { background: #000; border-radius: 10px; margin: 20px 0; border: 1px solid #334155; }
|
||||||
|
.info { display: flex; justify-content: space-between; font-family: monospace; color: #94a3b8; margin-bottom: 10px; }
|
||||||
|
.controls { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; }
|
||||||
|
button { padding: 10px 16px; border: none; border-radius: 6px; cursor: pointer; font-weight: bold; transition: 0.2s; }
|
||||||
|
.btn-play { background: #10b981; color: white; }
|
||||||
|
.btn-stop { background: #ef4444; color: white; }
|
||||||
|
.btn-export { background: var(--accent); color: white; }
|
||||||
|
.btn-zoom { background: #475569; color: white; }
|
||||||
|
.btn-share { background: #f59e0b; color: white; display: none; }
|
||||||
|
#status { font-size: 0.8rem; margin-top: 10px; text-align: center; color: #64748b; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>Cortador Pro (Misma Calidad)</h3>
|
||||||
|
<input type="file" id="audioInput" accept="audio/*">
|
||||||
|
|
||||||
|
<div id="waveform"></div>
|
||||||
|
|
||||||
|
<div class="info">
|
||||||
|
<span id="totalTime">Total: 00:00</span>
|
||||||
|
<span id="selectionTime">Selección: 00:00 - 00:00</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<button class="btn-zoom" onclick="ws.zoom(ws.options.minPxPerSec + 30)">Zoom +</button>
|
||||||
|
<button class="btn-zoom" onclick="ws.zoom(ws.options.minPxPerSec - 30)">Zoom -</button>
|
||||||
|
<button class="btn-play" id="btnPlay">Probar</button>
|
||||||
|
<button class="btn-stop" id="btnStop">Parar</button>
|
||||||
|
<button class="btn-export" id="btnExport">Exportar</button>
|
||||||
|
<button class="btn-share" id="btnShare">Compartir</button>
|
||||||
|
</div>
|
||||||
|
<p id="status">Listo.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let ws, wsRegions, activeRegion, lastBlob, originalFileType;
|
||||||
|
const audioInput = document.getElementById('audioInput');
|
||||||
|
const status = document.getElementById('status');
|
||||||
|
|
||||||
|
ws = WaveSurfer.create({
|
||||||
|
container: '#waveform',
|
||||||
|
waveColor: '#4f46e5',
|
||||||
|
progressColor: '#818cf8',
|
||||||
|
height: 120
|
||||||
|
});
|
||||||
|
|
||||||
|
wsRegions = ws.registerPlugin(WaveSurfer.Regions.create());
|
||||||
|
|
||||||
|
audioInput.onchange = (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (file) {
|
||||||
|
originalFileType = file.type; // Guardamos el formato original
|
||||||
|
ws.load(URL.createObjectURL(file));
|
||||||
|
document.getElementById('btnShare').style.display = 'none';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.on('ready', () => {
|
||||||
|
const duration = ws.getDuration();
|
||||||
|
document.getElementById('totalTime').innerText = `Total: ${formatTime(duration)}`;
|
||||||
|
wsRegions.clearRegions();
|
||||||
|
activeRegion = wsRegions.addRegion({
|
||||||
|
start: 0,
|
||||||
|
end: Math.min(duration, 10),
|
||||||
|
color: 'rgba(59, 130, 246, 0.3)',
|
||||||
|
drag: true, resize: true
|
||||||
|
});
|
||||||
|
updateSelectionLabel();
|
||||||
|
});
|
||||||
|
|
||||||
|
wsRegions.on('region-updated', updateSelectionLabel);
|
||||||
|
|
||||||
|
function updateSelectionLabel() {
|
||||||
|
if (activeRegion) {
|
||||||
|
document.getElementById('selectionTime').innerText =
|
||||||
|
`Selección: ${formatTime(activeRegion.start)} - ${formatTime(activeRegion.end)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(s) {
|
||||||
|
return new Date(s * 1000).toISOString().substr(14, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('btnPlay').onclick = () => activeRegion && activeRegion.play();
|
||||||
|
document.getElementById('btnStop').onclick = () => ws.stop();
|
||||||
|
|
||||||
|
// EXPORTACIÓN CON MEDIARECORDER (Mantiene formato de origen/comprimido)
|
||||||
|
document.getElementById('btnExport').onclick = async () => {
|
||||||
|
if (!activeRegion) return;
|
||||||
|
status.innerText = "Procesando...";
|
||||||
|
|
||||||
|
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
const sourceBuffer = ws.getDecodedData();
|
||||||
|
const duration = activeRegion.end - activeRegion.start;
|
||||||
|
|
||||||
|
const offlineCtx = new OfflineAudioContext(
|
||||||
|
sourceBuffer.numberOfChannels,
|
||||||
|
duration * sourceBuffer.sampleRate,
|
||||||
|
sourceBuffer.sampleRate
|
||||||
|
);
|
||||||
|
|
||||||
|
const source = offlineCtx.createBufferSource();
|
||||||
|
source.buffer = sourceBuffer;
|
||||||
|
source.connect(offlineCtx.destination);
|
||||||
|
source.start(0, activeRegion.start, duration);
|
||||||
|
|
||||||
|
const renderedBuffer = await offlineCtx.startRendering();
|
||||||
|
|
||||||
|
// Grabación del Stream para comprimir
|
||||||
|
const destination = audioCtx.createMediaStreamDestination();
|
||||||
|
const recorder = new MediaRecorder(destination.stream);
|
||||||
|
const chunks = [];
|
||||||
|
|
||||||
|
recorder.ondataavailable = (e) => chunks.push(e.data);
|
||||||
|
recorder.onstop = () => {
|
||||||
|
lastBlob = new Blob(chunks, { type: originalFileType || 'audio/mp4' });
|
||||||
|
const url = URL.createObjectURL(lastBlob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `cut_${audioInput.files[0].name}`;
|
||||||
|
a.click();
|
||||||
|
status.innerText = "¡Exportado!";
|
||||||
|
document.getElementById('btnShare').style.display = 'inline-block';
|
||||||
|
};
|
||||||
|
|
||||||
|
const playSource = audioCtx.createBufferSource();
|
||||||
|
playSource.buffer = renderedBuffer;
|
||||||
|
playSource.connect(destination);
|
||||||
|
|
||||||
|
recorder.start();
|
||||||
|
playSource.start();
|
||||||
|
playSource.onended = () => recorder.stop();
|
||||||
|
};
|
||||||
|
|
||||||
|
// COMPARTIR (Botón 3 bolitas nativo Android)
|
||||||
|
document.getElementById('btnShare').onclick = async () => {
|
||||||
|
if (!lastBlob) return;
|
||||||
|
const file = new File([lastBlob], `cut_${audioInput.files[0].name}`, { type: lastBlob.type });
|
||||||
|
if (navigator.canShare && navigator.canShare({ files: [file] })) {
|
||||||
|
await navigator.share({
|
||||||
|
files: [file],
|
||||||
|
title: 'Audio Cortado',
|
||||||
|
text: 'Compartido desde Audio Cutter PWA'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
154
index.html.v1
Normal file
154
index.html.v1
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Audio Cutter PWA</title>
|
||||||
|
<link rel="manifest" href="manifest.json">
|
||||||
|
<script src="wavesurfer.min.js"></script>
|
||||||
|
<script src="plugins/regions.min.js"></script>
|
||||||
|
<style>
|
||||||
|
body { font-family: sans-serif; background: #121212; color: white; display: flex; flex-direction: column; align-items: center; padding: 20px; }
|
||||||
|
#waveform { width: 100%; max-width: 800px; background: #222; border-radius: 8px; margin: 20px 0; }
|
||||||
|
.controls { display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
|
||||||
|
button { padding: 10px 20px; border: none; border-radius: 5px; cursor: pointer; font-weight: bold; }
|
||||||
|
.btn-play { background: #4CAF50; color: white; }
|
||||||
|
.btn-export { background: #2196F3; color: white; }
|
||||||
|
input[type="file"] { margin-bottom: 20px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<h1>Cortador de Audio PWA</h1>
|
||||||
|
<input type="file" id="audioInput" accept="audio/*">
|
||||||
|
|
||||||
|
<div id="waveform"></div>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<button class="btn-play" id="btnPlay">Reproducir Selección</button>
|
||||||
|
<button class="btn-export" id="btnExport">Exportar Corte</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let ws, wsRegions;
|
||||||
|
const audioInput = document.getElementById('audioInput');
|
||||||
|
const btnPlay = document.getElementById('btnPlay');
|
||||||
|
const btnExport = document.getElementById('btnExport');
|
||||||
|
|
||||||
|
// Inicializar Wavesurfer
|
||||||
|
ws = WaveSurfer.create({
|
||||||
|
container: '#waveform',
|
||||||
|
waveColor: '#4F4A85',
|
||||||
|
progressColor: '#383351',
|
||||||
|
responsive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Plugin de regiones (para seleccionar el área)
|
||||||
|
wsRegions = ws.registerPlugin(WaveSurfer.Regions.create());
|
||||||
|
|
||||||
|
audioInput.onchange = (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (file) {
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
ws.load(url);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.on('ready', () => {
|
||||||
|
wsRegions.clearRegions();
|
||||||
|
wsRegions.addRegion({
|
||||||
|
start: 0,
|
||||||
|
end: ws.getDuration() / 4,
|
||||||
|
color: 'rgba(0, 255, 0, 0.3)',
|
||||||
|
drag: true,
|
||||||
|
resize: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
btnPlay.onclick = () => {
|
||||||
|
const region = Object.values(wsRegions.getRegions())[0];
|
||||||
|
if (region) region.play();
|
||||||
|
};
|
||||||
|
|
||||||
|
btnExport.onclick = async () => {
|
||||||
|
const region = Object.values(wsRegions.getRegions())[0];
|
||||||
|
if (!region) return alert("Selecciona un área primero");
|
||||||
|
|
||||||
|
const originalBuffer = ws.getDecodedData();
|
||||||
|
const start = region.start;
|
||||||
|
const end = region.end;
|
||||||
|
|
||||||
|
const segmentBuffer = cutAudio(originalBuffer, start, end);
|
||||||
|
downloadAudio(segmentBuffer);
|
||||||
|
};
|
||||||
|
|
||||||
|
function cutAudio(buffer, start, end) {
|
||||||
|
const sampleRate = buffer.sampleRate;
|
||||||
|
const frameCount = (end - start) * sampleRate;
|
||||||
|
const newBuffer = new AudioContext().createBuffer(buffer.numberOfChannels, frameCount, sampleRate);
|
||||||
|
|
||||||
|
for (let i = 0; i < buffer.numberOfChannels; i++) {
|
||||||
|
const channelData = buffer.getChannelData(i).slice(start * sampleRate, end * sampleRate);
|
||||||
|
newBuffer.copyToChannel(channelData, i);
|
||||||
|
}
|
||||||
|
return newBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadAudio(buffer) {
|
||||||
|
// Conversión simple a WAV para exportación rápida
|
||||||
|
const wavData = bufferToWav(buffer);
|
||||||
|
const blob = new Blob([wavData], { type: 'audio/wav' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement('a');
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = "corte_audio.wav";
|
||||||
|
anchor.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper para convertir AudioBuffer a formato WAV
|
||||||
|
function bufferToWav(buffer) {
|
||||||
|
let numOfChan = buffer.numberOfChannels,
|
||||||
|
length = buffer.length * numOfChan * 2 + 44,
|
||||||
|
bufferArr = new ArrayBuffer(length),
|
||||||
|
view = new DataView(bufferArr),
|
||||||
|
channels = [], i, sample,
|
||||||
|
offset = 0,
|
||||||
|
pos = 0;
|
||||||
|
|
||||||
|
function setUint16(data) { view.setUint16(pos, data, true); pos += 2; }
|
||||||
|
function setUint32(data) { view.setUint32(pos, data, true); pos += 4; }
|
||||||
|
|
||||||
|
setUint32(0x46464952); // "RIFF"
|
||||||
|
setUint32(length - 8); // file length
|
||||||
|
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
|
||||||
|
setUint32(0x61746164); // "data" chunk
|
||||||
|
setUint32(length - pos - 4); // chunk length
|
||||||
|
|
||||||
|
for(i=0; i<buffer.numberOfChannels; i++) channels.push(buffer.getChannelData(i));
|
||||||
|
while(pos < length) {
|
||||||
|
for(i=0; i<numOfChan; i++) { // interleave channels
|
||||||
|
sample = Math.max(-1, Math.min(1, channels[i][offset]));
|
||||||
|
sample = (sample < 0 ? sample * 0x8000 : sample * 0x7FFF);
|
||||||
|
view.setInt16(pos, sample, true);
|
||||||
|
pos += 2;
|
||||||
|
}
|
||||||
|
offset++;
|
||||||
|
}
|
||||||
|
return bufferArr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registro del Service Worker para PWA
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
navigator.serviceWorker.register('sw.js');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
22
manifest.json
Normal file
22
manifest.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "Audio Cutter",
|
||||||
|
"short_name": "Cutter",
|
||||||
|
"start_url": "index.html",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#121212",
|
||||||
|
"theme_color": "#2196F3",
|
||||||
|
"icons": [{ "src": "icon.png", "sizes": "512x512", "type": "image/png" }],
|
||||||
|
"share_target": {
|
||||||
|
"action": "/index.html",
|
||||||
|
"method": "POST",
|
||||||
|
"enctype": "multipart/form-data",
|
||||||
|
"params": {
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"name": "audio_file",
|
||||||
|
"accept": ["audio/*"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
6
sw.js
Normal file
6
sw.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
self.addEventListener('install', (e) => {
|
||||||
|
e.waitUntil(caches.open('v1').then(cache => cache.addAll(['index.html'])));
|
||||||
|
});
|
||||||
|
self.addEventListener('fetch', (e) => {
|
||||||
|
e.respondWith(caches.match(e.request).then(res => res || fetch(e.request)));
|
||||||
|
});
|
||||||
1
wavesurfer.min.js
vendored
Normal file
1
wavesurfer.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user