Coronado por el logo mas feo que he hecho a partir de una tijera y un altavoz, aquí estamos de nuevo con un ensayo de PWA.
No es que no hayan alternativas ni pienso superar lo que ya existe. El problema es que hay muchísimas alternativas, o muy amplias para una sola tarea, o especificas, pero rellena de publicidad. Este proyecto es una excusa para tener un cortador de audio simple y estudiar aplicaciones web progresivas. Un win-win, ¿no crees?
Por supuesto, la aplicación quedará documentada y servida, con la opción de descargar desde mi repo. Analizaremos como fue desarrollada y bajo que principios, sirviendo como un proyecto mas para mi estantería.
Necesitaba un cortador simple y sin publicidad. Asi, sin rodeos. Pensé en un inicio en usar simplemente FFMPEG para esta tarea, pero también quería que sea portable. FFMPEG funciona bien para linux, windows y mas plataformas, pero esta tarea era muy concreta. Esto me hizo descartar una implementación en webassembly, que le permite funcionar de forma nativa en un navegador. En los primeros ensayos funcionó de maravilla, pero iba en contra del principio de minimalismo que buscaba. Aparte claro, de que agregaba carga al procesador y memoria que no eran necesarias.
https://github.com/ffmpegwasm/ffmpeg.wasm
En principio, el aprovechamiento de los archivos de audio en los navegadores modernos es algo que se puede usar de forma nativa. Se pueden usar formatos como mp3, mp4, ogg, wav, etc, pero para exportar, solo se puede entregar el archivo en formato WAV, esto es algo que analizaremos posteriormente.
Como no quiero todavia involucrarme en el desarrollo android y queria hacerlo multiplataforma, pense en usar flutter. Tras unos cuantos fracasos por inexperiencia y porque flutter tiene limitaciones que me sorprendieron, volvi a buscar una alternativa. El resultado es que el desarrollo funcional actual está en una aplicacion PWA universal.
Estos son los requisitos:
Tras unos cuantos bocetos, la primera opcion que parecia adecuada, era Web Audio API. De forma sencilla y sin mucho lio, permite utilizar los formatos de audio de entrada nativos del navegador, pero solo permite salir el formato WAV. Es la forma mas rapida que hay, pero la menos flexible. Dado que no quiero depender de demasiados complementos externos, es mas rapido de utilizar que el ffmpeg wasm.
Por supuesto, tambien para dar un poco de estilo, utilicé wavesurfer, para una representacion grafica del archivo de audio.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 |
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[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'); } |
Dado que me interesa que el formato de entrada, como minimo sea igual al de salida, hice este cambio.
A diferencia del método anterior que reconstruía el audio bit por bit, el MediaRecorder captura el flujo de audio mientras se procesa y lo empaqueta en el formato que el navegador soporta nativamente (usualmente el mismo que el de entrada). El codigo resultante por supuesto, tambien es mas compacto.
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' }); } };
Con esto, el codigo ya es funcional incluso al abrirlo como archivo local. Podrias copiar el codigo a tu compu y no necesitarias de un servidor para generar localhost.
una vez listo el desarrollo, queda apuntar al proposito del proyecto; que sea una App offline. Pero como sabras, javascript es un lenguaje para la web, asi que tenemos que aprovechar una tecnologia ampliamente aceptada (aun no por todos) para asegurar el funcionamiento offline; PWA.
Con PWA puedes hacer software que funciona como si fuera una app nativa. Claro, necesitas preparativos previos, pero principalmente necesitas un archivo manifest.json y un archivo sw.js que, registrados en tu app javascript, le indican al navegador lo que necesitamos para que funcione offline.
Todo lo que haremos a continuacion puede realizarse asistidos por la pagina PWABuilder
Primero, necesitamos un manifest.json
{
"name": "Audio Cutter PWA",
"short_name": "Cutter",
"start_url": "index.html",
"display": "standalone",
"background_color": "#121212",
"theme_color": "#4A90E2",
"description": "Simple recortador de audio para PWA. Hecho por Drk0027",
"share_target": {
"action": "/index.html",
"method": "POST",
"enctype": "multipart/form-data",
"params": {
"files": [
{
"name": "audio_file",
"accept": [
"audio/*"
]
}
]
}
},
"user_preferences": {
"color_scheme_dark": {
"theme_color": "#121212",
"background_color": "#121212"
}
},
"screenshots" : [
{
"src": "screenshot.png",
"sizes": "740x383",
"type": "image/png",
"platform": "wide"
}
],
"orientation": "any",
"id": "audiocutter",
"categories": ["utilities", "music"]
}
El archivo manifest.json le permite al navegador que caracteristicas tiene la app. PWABuilder tiene listada una amplia cantidad de funciones casi nativas que permite PWA, por lo que no es mala idea hacer un desarrollo en este sistema si no necesitas trabajar a bajo nivel.
Lo siguiente que hay que hacer es el archivo sw.js
Este archivo permite al navegador saber que es lo que se mantendrá offline y la forma en la que se actualizara en caso de cambios en el servidor.
const CACHE_NAME = 'v1_cache_mi_pwa'; // Lista de archivos que quieres que funcionen sin internet const urlsToCache = [ '/', '/index.html', '/plugins/regions.min.js', '/wavesurfer.min.js' ]; // 1. Evento Install: Guarda los archivos en la caché al instalar la PWA self.addEventListener('install', e => { e.waitUntil( caches.open(CACHE_NAME) .then(cache => { return cache.addAll(urlsToCache); }) .then(() => self.skipWaiting()) // Fuerza la activación inmediata ); }); // 2. Evento Activate: Limpia cachés antiguas para que siempre tengas la última versión self.addEventListener('activate', e => { const cacheWhitelist = [CACHE_NAME]; e.waitUntil( caches.keys().then(cacheNames => { return Promise.all( cacheNames.map(cacheName => { if (cacheWhitelist.indexOf(cacheName) === -1) { return caches.delete(cacheName); } }) ); }) ); }); // 3. Evento Fetch: Intercepta las peticiones y sirve desde la caché si existe self.addEventListener('fetch', e => { e.respondWith( caches.match(e.request) .then(res => { if (res) { // Si el archivo está en caché, lo devuelve sin ir a internet return res; } // Si no está, lo busca en la red return fetch(e.request); }) ); }); self.addEventListener('fetch', (e) => { e.respondWith(caches.match(e.request).then(res => res || fetch(e.request))); });
La magia de PWA es que una vez instalado, no necesitas descargar nada. Todo se actualiza de forma transparente y no necesitas atormentar al cliente. Por supuesto, es una buena practica avisar al cliente si actualizas algo, asi que he agregado esa funcion tambien en este ejemplo.
Hay que registrar en el sw.jscada archivo que quieres cachear para mantener offline. Yo por pereza solo puse unos cuantos, pero se recomienda que sean todos. Hay maneras mas automaticas de lograrlo, pero eso es tarea para una proxima clase.
Por ultimo, necesitamos registrar en el index el archivo manifest.jsony el service worker de la siguiente manera.
<link rel="manifest" href="manifest.json"> <script> if ('serviceWorker' in navigator) { navigator.serviceWorker.register('./sw.js') .then(reg => console.log('Registro exitoso', reg)) .catch(err => console.warn('Error al registrar', err)); } if ('serviceWorker' in navigator) { navigator.serviceWorker.register('./sw.js').then(reg => { reg.onupdatefound = () => { const installingWorker = reg.installing; installingWorker.onstatechange = () => { if (installingWorker.state === 'installed') { if (navigator.serviceWorker.controller) { // Aquí es donde le avisas al usuario alert('Nueva versión disponible. Por favor, recarga la página.'); } } }; }; }); } if ('serviceWorker' in navigator) { window.addEventListener('load', () => { navigator.serviceWorker.register('./sw.js').then(reg => { // Detecta si hay una actualización esperando reg.addEventListener('updatefound', () => { const newWorker = reg.installing; newWorker.addEventListener('statechange', () => { // Cuando el nuevo SW se ha instalado completamente if (newWorker.state === 'installed' && navigator.serviceWorker.controller) { // Crea una confirmación para el usuario const r = confirm("¡Hay una nueva versión disponible! ¿Quieres actualizar ahora?"); if (r === true) { window.location.reload(); // Recarga la página para aplicar cambios } } }); }); }); }); } </script>
De paso agregamos un detector que permitira actualizar los archivos cacheados por si hacemos alguna mejora a la app desde el servidor.
En realidad no es taaan necesario, pero igual lo hago. Esto para probar las funciones que ofrece PWA de forma nativa, que entre otras cosas, incuye funciones del boton de compartir, y tambien de «abrir con».
Por el momento, y a modo de prueba para obtanium, he agregado al repositorio publico el directorio de release, con el fin de comprobar si es posible utilizarlo de esta manera. Puedes encontrarlo en esta url si te interesa.
https://git.interlan.ec/Drk0027/audio-cutter-pwa/releases
El APK fue creado usando PWABuilder. Tras ajustar todos los requerimientos, me dió los archivos necesarios para el despliegue multiplataforma. Deja incluso firmando el APK.
Como novedad y aprendisaje, resulta que obtanium no obtiene desde una carpeta release especifica, sino que tiene un apartado llamado lanzamientos, por lo que poner en el repo los releases es innecesario, en proximas revisiones eliminaré los archivos innecesarios.
Al exportar con la pagina, me dio este log, que me daba la impresion de que fallaba por el tiempo que tomo. No fallaba, si que toma tiempo jajaja.
Querying for job…
2026-01-30T02:04:25.557Z [info]: Generating app package for https://audio-cutter-pwa.interlan.ec
2026-01-30T02:04:25.558Z [info]: Creating temp directory…
2026-01-30T02:04:25.564Z [info]: Creating signing information…
2026-01-30T02:04:25.565Z [info]: Using Bubblewrap to generate app package…
2026-01-30T02:04:25.566Z [info]: Creating Trusted Web Activity (TWA) project…
2026-01-30T02:04:27.965Z [info]: Building the app package with Gradle. This can take a few minutes…
2026-01-30T02:04:45.382Z [info]: Signing APK…
2026-01-30T02:04:50.621Z [info]: Signing the app package…
2026-01-30T02:04:55.960Z [info]: App package signed successfully
2026-01-30T02:04:55.960Z [info]: Generating asset links…
2026-01-30T02:05:00.726Z [info]: Digital Asset Links file generated at /tmp/pwabuilder-cloudapk–19-eBPgGJdJxsEV/app/build/outputs/apk/release/assetlinks.json
2026-01-30T02:05:00.726Z [info]: Building App Bundle…
2026-01-30T02:05:00.726Z [info]: Generating app bundle
2026-01-30T02:05:13.770Z [info]: App bundle built successfully.
2026-01-30T02:05:13.770Z [info]: Successfully created app package for https://audio-cutter-pwa.interlan.ec
2026-01-30T02:05:13.771Z [info]: Process completed in 48 seconds
2026-01-30T02:05:13.771Z [info]: Zipping app package…
2026-01-30T02:05:15.965Z [info]: App package zipped successfully.
2026-01-30T02:05:15.965Z [info]: Successfully generated Google Play package. Saving zip file…
2026-01-30T02:05:16.492Z [info]: Successfully uploaded package zip file [«-audio-cutter-pwainterlanec-409230zip»]Package created successfully. Download has begun.
La aplicacion convertida a APK para android no se ve especialmente nativa. Tambien es cierto que elegi una interfaz mas apta para una pantalla grande de computadora, por lo que da la impresion de ser muy estrecho. Aun asi, es utilizable. funciona muy bien y no es incomodo en la interfaz tactil. Eso si, tengo que hacer mas pruebas para evaluar el rendimiento, pero todo apunta a que es mejor volver a la exportacion mediante WAV, debido a que el resultado es muy variable. En el telefono de pruebas, un Infinix Smart 8, bastante basico, tardo casi 5 minutos en generar el resultado y la calidad de sonido es bastante baja. Pero en una computadora, la salida tiene una calidad similar a la de origen.

Por otra parte, entre las funciones nativas que ofrece PWA es la opcion de «Abrir con» que permite utilizar los elementos nativos de android para elegir la aplicacion con la que se quiere abrir una lista limitada de archivos, en este caso, archivos de audio.

Por supuesto, no es funcional puesto que solo lo hice para probar el resultado, pues PWABuilder ofrecia esta capacidad. Para proximas versiones podria aprender a utilizarla.
Mis experimentos con PWA no son recientes, ya antes he probado hacerlo con el juego de la vida y el SSB y por supuesto, no han sido tan prosperos como este pues nunca me atrevi a utilizar el PWABuilder. Actualmente podrian funcionar de esa manera, podria ponerlos en esa pagina y podrian ser funcionales o les faltaria algo que deba cambiar para que trabaje, no lo se, debo revisar.
Pero es muy interesante en el sentido de que este camino se puede tomar para hacer aplicaciones universales bastante cercanas a funcionalidad a aplicaciones nativas. Por supuesto, hay limites y tambien es que javascript es un lenguaje muy para la web. Aunque la app sea offline, la necesidad de llamada a APIS hace que realmente no haya tanta practicidad a la hora de utilizarlas. A menos que se haga un desarrollo mas pesado en el que se guarden recursos localmente y que se actualicen al volver a tener internet.
Me gustaria ver si es posible hacer un juego con PWA y Godot. O solo hacer un juego en godot. Eso es otra cosa que sigo teniendo pendiente porque aunque trabajo mucho en temas de computacion e informatica, tambien soy un artista. Seguro que se nota mas cuando empiece a poblar mi otro blog jajaja.
Publicado originalmente en: https://interlan.ec/blog/2026/02/20/proyecto-super-simple-audio-cutter/