diff --git a/e2e/reproductor.e2e.ts b/e2e/reproductor.e2e.ts index a4ef941..02793b0 100644 --- a/e2e/reproductor.e2e.ts +++ b/e2e/reproductor.e2e.ts @@ -68,4 +68,23 @@ test.describe('Reproductor', () => { await cola.getByRole('button', { name: /El último tren/ }).click(); await expect(temaEnBarra(page, 'el-ultimo-tren')).toBeVisible(); }); + + test('dibuja la onda precalculada sin descargar otro audio', async ({ page }) => { + await page.goto('/albumes/cupido-sin-flechas'); + + const respuesta = page.waitForResponse((r) => + new URL(r.url()).pathname.endsWith( + '/ondas/cupido-sin-flechas/01-amame-esta-noche-nada-mas.json' + ) + ); + await page.getByRole('button', { name: 'Reproducir', exact: true }).first().click(); + + const onda = page.getByRole('region', { name: 'Reproductor' }).locator('[data-forma-onda]'); + await expect(onda).toBeVisible(); + expect((await respuesta).status()).toBe(200); + // Un único trazo por estado (pendiente y escuchado), no cientos de nodos + // que hubiera que actualizar en cada cuadro de la reproducción. + await expect(onda.locator('path')).toHaveCount(2); + expect((await onda.locator('path').first().getAttribute('d'))?.match(/M/g)).toHaveLength(128); + }); }); diff --git a/media/LEEME.md b/media/LEEME.md index 9ae0ed2..51814dc 100644 --- a/media/LEEME.md +++ b/media/LEEME.md @@ -5,21 +5,22 @@ directorio estático: `/descargas/…` comprueba la sesión y la compra antes de entregar cualquier archivo. La escucha pública usa una representación distinta y completa, generada a -calidad reducida en `static/escucha`. Las muestras recortadas de -`static/muestras` se conservan solo como legado mientras se completa la -migración. +calidad reducida en `static/escucha`. De ella sale además un resumen de +amplitudes en `static/ondas`, que permite dibujar el audio sin descargarlo ni +decodificarlo en el navegador. Las muestras recortadas de `static/muestras` se +conservan solo como legado mientras se completa la migración. ## Flujo ```bash -npm run medios # genera o actualiza las copias completas de escucha +npm run medios # genera las escuchas y sus formas de onda npm run db:medios # mide, registra y enlaza todas las representaciones ``` `npm run medios` también corre antes de `npm run dev` y `npm run build`. Recorre todos los másteres de `media/audio`, incluidos los importados como álbum sin -ficha Markdown. El perfil de transcodificación está centralizado en -`scripts/config/audio.mjs`; actualmente produce MP3 a 96 kbps. +ficha Markdown. Los perfiles están centralizados en `scripts/config/audio.mjs`: +actualmente produce MP3 a 96 kbps y resume cada pista en 128 picos. ## Convención @@ -33,13 +34,18 @@ media/audio/ static/escucha/ + +static/ondas/ + ``` La ruta relativa es estable. El máster `media/audio/cupido-sin-flechas/01-tema.mp3` produce -`static/escucha/cupido-sin-flechas/01-tema.mp3`. En la base, `cancion_audio` -distingue explícitamente los usos `master`, `descarga` y `stream`; el navegador -solo recibe la URL del último. +`static/escucha/cupido-sin-flechas/01-tema.mp3` y +`static/ondas/cupido-sin-flechas/01-tema.json`. En la base, `cancion_audio` +distingue explícitamente los usos `master`, `descarga` y `stream`; la URL de la +onda se deriva de la del último, por lo que no hay otra columna que pueda quedar +desincronizada. ## Subidas @@ -52,7 +58,8 @@ en cada ejecución para que no pase inadvertido. `MEDIA_DIR` configura la raíz del almacenamiento privado; por defecto es `./media/audio`. Los másteres deben copiarse al servidor aparte del build. Las -copias de `static/escucha` sí forman parte de los estáticos desplegables. +copias de `static/escucha` y los resúmenes de `static/ondas` sí forman parte de +los estáticos desplegables. Para comprobar la integridad después de migrar o importar: diff --git a/scripts/config/audio.mjs b/scripts/config/audio.mjs index a9edd55..c8f2ca1 100644 --- a/scripts/config/audio.mjs +++ b/scripts/config/audio.mjs @@ -5,3 +5,16 @@ export const PERFIL_ESCUCHA = Object.freeze({ codecFfmpeg: 'libmp3lame', bitrateKbps: 96 }); + +/** + * Perfil del dibujo de la onda. + * + * No se guarda el PCM ni se analiza en el navegador: se resumen ambos canales + * en 128 amplitudes normalizadas. Eso ocupa alrededor de 550 bytes por tema y + * deja separación visible entre barras incluso en el reproductor de un móvil. + */ +export const PERFIL_ONDA = Object.freeze({ + id: 'picos-128-v1', + picos: 128, + frecuenciaHz: 8_000 +}); diff --git a/scripts/medios.mjs b/scripts/medios.mjs index 35f6496..d7de88d 100644 --- a/scripts/medios.mjs +++ b/scripts/medios.mjs @@ -14,7 +14,9 @@ * 2. Recorre todos los másteres de `media/audio`, también los importados * directamente como álbum, y genera a 96 kbps cualquier copia completa de * escucha que falte o esté desactualizada. - * 3. Si no tiene duración escrita, la mide y la escribe. + * 3. Resume cada escucha en una forma de onda ligera, lista para dibujar sin + * descargar ni decodificar el audio en el navegador. + * 4. Si no tiene duración escrita, la mide y la escribe. * * Corre solo antes de `npm run dev` y de `npm run build`, así que subir un tema * desde el panel y arrancar basta. También se puede llamar a mano: @@ -30,12 +32,13 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { load as leerYaml } from 'js-yaml'; -import { PERFIL_ESCUCHA } from './config/audio.mjs'; +import { PERFIL_ESCUCHA, PERFIL_ONDA } from './config/audio.mjs'; const RAIZ = path.resolve(import.meta.dirname, '..'); const CANCIONES = path.join(RAIZ, 'src/content/canciones'); const MASTERS = path.join(RAIZ, 'media/audio'); const ESCUCHAS = path.join(RAIZ, 'static/escucha'); +const ONDAS = path.join(RAIZ, 'static/ondas'); /** Carpeta donde deja el panel lo que se sube; nada se queda aquí. */ const BANDEJA = 'subidas'; @@ -52,7 +55,19 @@ function ejecutar(programa, argumentos) { return r.stdout.trim(); } -const hayFfmpeg = Boolean(ejecutar('ffprobe', ['-version'])); +/** ffmpeg escribe el PCM por stdout; aquí no se puede convertir a texto. */ +function ejecutarBinario(programa, argumentos) { + const r = spawnSync(programa, argumentos, { + encoding: null, + // A 8 kHz, una hora en mono ocupa 57,6 MB. Las canciones reales quedan + // muy por debajo, pero el margen evita el límite de 1 MB de spawnSync. + maxBuffer: 64 * 1024 * 1024 + }); + if (r.error || r.status !== 0) return undefined; + return r.stdout; +} + +const hayFfmpeg = Boolean(ejecutar('ffprobe', ['-version']) && ejecutar('ffmpeg', ['-version'])); /** Duración en segundos de un archivo de audio. */ function medir(archivo) { @@ -128,6 +143,11 @@ export function claveDeEscucha(clave) { return clave.replace(/\.[^.]+$/, '') + '.mp3'; } +/** Nombre del resumen visual equivalente a un máster o una escucha. */ +export function claveDeOnda(clave) { + return clave.replace(/\.[^.]+$/, '') + '.json'; +} + /** Argumentos deterministas de la transcodificación completa de escucha. */ export function argumentosDeEscucha(master, escucha) { return [ @@ -147,6 +167,57 @@ export function argumentosDeEscucha(master, escucha) { ]; } +/** + * Decodifica a PCM mono de baja frecuencia: para medir una envolvente no hacen + * falta los 44.100 valores por segundo del máster. + */ +export function argumentosDeOnda(escucha) { + return [ + '-v', + 'error', + '-i', + escucha, + '-vn', + '-ac', + '1', + '-ar', + String(PERFIL_ONDA.frecuenciaHz), + '-f', + 's16le', + 'pipe:1' + ]; +} + +/** + * Convierte PCM signed-16 little-endian en una cantidad fija de amplitudes. + * + * Se usa RMS por bloque, no el máximo aislado: un chasquido no debería llenar + * una barra entera. El percentil 95 evita que un único golpe baje visualmente + * el resto, y la raíz cuadrada abre los pasajes suaves sin falsear el silencio. + */ +export function resumirPicos(pcm, cantidad = PERFIL_ONDA.picos) { + if (!Number.isInteger(cantidad) || cantidad <= 0) return []; + const total = Math.floor(pcm.length / 2); + if (total === 0) return Array(cantidad).fill(0); + + const amplitudes = Array.from({ length: cantidad }, (_, i) => { + const inicio = Math.floor((i * total) / cantidad); + const fin = Math.max(inicio + 1, Math.floor(((i + 1) * total) / cantidad)); + let cuadrados = 0; + for (let muestra = inicio; muestra < Math.min(fin, total); muestra++) { + const valor = pcm.readInt16LE(muestra * 2) / 32768; + cuadrados += valor * valor; + } + return Math.sqrt(cuadrados / Math.max(1, Math.min(fin, total) - inicio)); + }); + + const ordenadas = amplitudes.filter((v) => v > 0).sort((a, b) => a - b); + const techo = ordenadas[Math.ceil((ordenadas.length - 1) * 0.95)] ?? 0; + if (techo === 0) return Array(cantidad).fill(0); + + return amplitudes.map((valor) => Math.round(Math.min(1, Math.sqrt(valor / techo)) * 255)); +} + /** Genera la canción pública completa si falta o si el máster es más nuevo. */ function generarEscucha(clave) { const master = path.join(MASTERS, clave); @@ -176,6 +247,55 @@ function generarEscucha(clave) { ); } +/** `true` si el resumen existe, corresponde al perfil actual y no está viejo. */ +function ondaVigente(onda, escucha) { + if (!fs.existsSync(onda) || fs.statSync(onda).mtimeMs < fs.statSync(escucha).mtimeMs) + return false; + try { + const datos = JSON.parse(fs.readFileSync(onda, 'utf8')); + return ( + datos.perfil === PERFIL_ONDA.id && + Array.isArray(datos.picos) && + datos.picos.length === PERFIL_ONDA.picos && + datos.picos.every( + (valor) => Number.isInteger(valor) && Number(valor) >= 0 && Number(valor) <= 255 + ) + ); + } catch { + return false; + } +} + +/** Genera el resumen de la misma copia de baja calidad que oirá la persona. */ +function generarOnda(clave) { + const escucha = path.join(ESCUCHAS, claveDeEscucha(clave)); + const relativa = claveDeOnda(clave); + const onda = path.join(ONDAS, relativa); + + if (!fs.existsSync(escucha)) { + avisos.push(`${clave}: falta la escucha completa; no se puede generar su onda.`); + return; + } + if (ondaVigente(onda, escucha)) return; + if (!hayFfmpeg) { + avisos.push(`${clave}: falta la onda y no hay ffmpeg para generarla.`); + return; + } + + const pcm = ejecutarBinario('ffmpeg', argumentosDeOnda(escucha)); + if (!pcm) { + avisos.push(`${clave}: ffmpeg no pudo analizar la forma de onda.`); + return; + } + + fs.mkdirSync(path.dirname(onda), { recursive: true }); + fs.writeFileSync( + onda, + `${JSON.stringify({ perfil: PERFIL_ONDA.id, picos: resumirPicos(pcm) })}\n` + ); + hechos.push(`onda ${relativa} (${PERFIL_ONDA.picos} picos)`); +} + /** Todos los másteres reales, sin confundir la bandeja de entrada con catálogo. */ export function recorrerMasters(directorio = MASTERS) { if (!fs.existsSync(directorio)) return []; @@ -235,6 +355,7 @@ function procesar(archivo) { } generarEscucha(clave); + generarOnda(clave); if (!datos.duracion && hayFfmpeg) { const segundos = medir(path.join(MASTERS, clave)); @@ -264,6 +385,7 @@ function procesar(archivo) { } generarEscucha(clave); + generarOnda(clave); } if (tocado) { @@ -286,7 +408,9 @@ export function principal() { } for (const master of recorrerMasters()) { - generarEscucha(path.relative(MASTERS, master).split(path.sep).join('/')); + const clave = path.relative(MASTERS, master).split(path.sep).join('/'); + generarEscucha(clave); + generarOnda(clave); } // Un archivo en la bandeja que no reclama ninguna canción se quedaría ahí para diff --git a/scripts/medios.spec.mjs b/scripts/medios.spec.mjs index 63e8ef3..0780052 100644 --- a/scripts/medios.spec.mjs +++ b/scripts/medios.spec.mjs @@ -4,12 +4,15 @@ import os from 'node:os'; import path from 'node:path'; import { argumentosDeEscucha, + argumentosDeOnda, aSlug, claveCanonica, claveDeEscucha, + claveDeOnda, comoDuracion, enLaBandeja, - recorrerMasters + recorrerMasters, + resumirPicos } from './medios.mjs'; describe('claveCanonica', () => { @@ -113,3 +116,36 @@ describe('escucha pública', () => { expect(args.at(-1)).toBe('static/escucha/singles/dorina.mp3'); }); }); + +describe('forma de onda', () => { + it('conserva la jerarquía y cambia el audio por JSON', () => { + expect(claveDeOnda('disco/01-tema.wav')).toBe('disco/01-tema.json'); + }); + + it('decodifica a PCM mono ligero', () => { + const args = argumentosDeOnda('static/escucha/disco/01-tema.mp3'); + expect(args).toContain('1'); + expect(args).toContain('8000'); + expect(args).toContain('s16le'); + expect(args.at(-1)).toBe('pipe:1'); + }); + + it('resume amplitudes, conserva silencios y normaliza el nivel', () => { + const pcm = Buffer.alloc(800); + // Cuatro bloques: silencio, pasaje suave y dos pasajes fuertes. + for (let i = 100; i < 200; i++) pcm.writeInt16LE(1_000, i * 2); + for (let i = 200; i < 400; i++) pcm.writeInt16LE(i % 2 ? 10_000 : -10_000, i * 2); + + const picos = resumirPicos(pcm, 4); + expect(picos).toHaveLength(4); + expect(picos[0]).toBe(0); + expect(picos[1]).toBeGreaterThan(0); + expect(picos[1]).toBeLessThan(picos[2]); + expect(picos[2]).toBe(255); + expect(picos[3]).toBe(255); + }); + + it('devuelve la anchura pedida incluso para un audio vacío', () => { + expect(resumirPicos(Buffer.alloc(0), 3)).toEqual([0, 0, 0]); + }); +}); diff --git a/src/lib/components/player/BarraProgreso.svelte b/src/lib/components/player/BarraProgreso.svelte index 66fb522..e6e02bb 100644 --- a/src/lib/components/player/BarraProgreso.svelte +++ b/src/lib/components/player/BarraProgreso.svelte @@ -15,18 +15,64 @@ tiempo: number; duracion: number; onbuscar: (segundos: number) => void; + /** JSON de amplitudes precalculadas. Sin él queda el carril convencional. */ + onda?: string; /** Desactiva la barra cuando no hay nada cargado. */ activa?: boolean; } - let { tiempo, duracion, onbuscar, activa = true }: Props = $props(); + let { tiempo, duracion, onbuscar, onda, activa = true }: Props = $props(); let arrastrando = $state(false); let valorLocal = $state(0); + let picos = $state([]); const maximo = $derived(duracion > 0 ? duracion : 0); const mostrado = $derived(arrastrando ? valorLocal : Math.min(tiempo, maximo)); const porcentaje = $derived(maximo > 0 ? (mostrado / maximo) * 100 : 0); + const anchoOnda = $derived(Math.max(1, picos.length * 2)); + const trazo = $derived( + picos + .map((pico, i) => { + const amplitud = Math.max(4, Math.round((pico / 255) * 88)); + const arriba = (100 - amplitud) / 2; + return `M${i * 2 + 1} ${arriba}V${arriba + amplitud}`; + }) + .join('') + ); + + /** Un archivo mal formado no debe romper el control de reproducción. */ + function leerPicos(datos: unknown): number[] { + if (!datos || typeof datos !== 'object') return []; + const candidatos = (datos as { picos?: unknown }).picos; + if (!Array.isArray(candidatos) || candidatos.length === 0 || candidatos.length > 512) return []; + return candidatos.every( + (valor) => Number.isInteger(valor) && Number(valor) >= 0 && Number(valor) <= 255 + ) + ? (candidatos as number[]) + : []; + } + + /* + * Se carga solo la onda de la pista actual. Cambiar de tema cancela la + * petición anterior y el caché HTTP hace que volver a ella no cueste nada. + * El fallo es silencioso porque debajo queda el input de rango de siempre. + */ + $effect(() => { + const fuente = onda; + picos = []; + if (!fuente) return; + + const controlador = new AbortController(); + void fetch(fuente, { signal: controlador.signal, cache: 'force-cache' }) + .then((respuesta) => (respuesta.ok ? respuesta.json() : undefined)) + .then((datos) => { + if (!controlador.signal.aborted) picos = leerPicos(datos); + }) + .catch(() => {}); + + return () => controlador.abort(); + }); function alMover(evento: Event & { currentTarget: HTMLInputElement }) { arrastrando = true; @@ -42,20 +88,39 @@
- +
+ {#if picos.length > 0} + + {:else if onda} + + + {/if} + + +
@@ -76,14 +141,77 @@ text-align: center; } - .pista { + .carril { + position: relative; flex: 1; + min-width: 0; + } + + .carril.con-onda { + height: 2.5rem; + padding: 0.2rem 0.35rem; + border: 1px solid var(--c-border); + border-radius: 0.7rem; + overflow: hidden; + } + + .carril.con-onda:focus-within { + border-color: var(--c-focus); + box-shadow: 0 0 0 2px color-mix(in oklab, var(--c-focus) 45%, transparent); + } + + .carril.apagado { + opacity: 0.5; + } + + .onda { + display: block; + width: 100%; + height: 100%; + overflow: visible; + } + + .onda path { + fill: none; + stroke-width: 1.35; + stroke-linecap: round; + vector-effect: non-scaling-stroke; + } + + .onda-pendiente { + stroke: color-mix(in oklab, var(--c-text-muted) 88%, transparent); + } + + .onda-escuchada { + stroke: var(--c-accent); + clip-path: inset(0 calc(100% - var(--avance)) 0 0); + } + + .reserva { + position: absolute; + inset-inline: 0.35rem; + top: 50%; + height: 2px; + background: var(--c-bg-sunken); + transform: translateY(-50%); + } + + .pista { + display: block; + width: 100%; appearance: none; height: 1rem; background: transparent; cursor: pointer; } + .con-onda .pista { + position: absolute; + inset: 0; + height: 100%; + margin: 0; + } + .pista:disabled { cursor: default; opacity: 0.5; @@ -94,7 +222,7 @@ * unica forma de tener "parte reproducida" en un input range sin duplicar * elementos. */ - .pista::-webkit-slider-runnable-track { + .carril:not(.con-onda) .pista::-webkit-slider-runnable-track { height: 4px; border-radius: 2px; /* @@ -110,7 +238,7 @@ ); } - .pista::-moz-range-track { + .carril:not(.con-onda) .pista::-moz-range-track { height: 4px; border-radius: 2px; /* @@ -126,7 +254,7 @@ ); } - .pista::-webkit-slider-thumb { + .carril:not(.con-onda) .pista::-webkit-slider-thumb { appearance: none; width: 12px; height: 12px; @@ -136,11 +264,80 @@ border: none; } - .pista::-moz-range-thumb { + .carril:not(.con-onda) .pista::-moz-range-thumb { width: 12px; height: 12px; border-radius: 50%; background: var(--c-accent); border: none; } + + /* + * El input sigue cubriendo toda la onda —teclado, ratón y tacto continúan + * siendo nativos—, pero su carril y su pulgar no tapan el dibujo. + */ + .con-onda .pista::-webkit-slider-runnable-track { + height: 100%; + background: transparent; + } + + .con-onda .pista::-moz-range-track { + height: 100%; + background: transparent; + } + + .con-onda .pista::-webkit-slider-thumb { + appearance: none; + width: 0.8rem; + height: 100%; + margin: 0; + background: transparent; + border: none; + } + + .con-onda .pista::-moz-range-thumb { + width: 0.8rem; + height: 100%; + background: transparent; + border: none; + } + + .pista:focus-visible { + outline: 2px solid var(--c-focus); + outline-offset: 2px; + border-radius: var(--radius-boton); + } + + .con-onda .pista:focus-visible { + /* El foco lo pinta el contenedor; aquí quedaría recortado por la onda. */ + outline: none; + } + + @media (max-width: 48rem) { + .progreso { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--sp-1) var(--sp-2); + } + + .tiempo { + grid-row: 1; + min-width: 0; + text-align: left; + } + + .tiempo:last-child { + grid-column: 2; + text-align: right; + } + + .carril { + grid-column: 1 / -1; + grid-row: 2; + } + + .carril.con-onda { + height: 4rem; + } + } diff --git a/src/lib/components/player/BarraReproductor.svelte b/src/lib/components/player/BarraReproductor.svelte index 1e20bd0..61a0064 100644 --- a/src/lib/components/player/BarraReproductor.svelte +++ b/src/lib/components/player/BarraReproductor.svelte @@ -111,13 +111,18 @@ {/if} diff --git a/src/lib/player/pista.ts b/src/lib/player/pista.ts index 6822cb6..b28f8ec 100644 --- a/src/lib/player/pista.ts +++ b/src/lib/player/pista.ts @@ -43,6 +43,7 @@ export function aPista(cancion: Cancion): PistaReproductor { cancionSlug: cancion.slug, titulo: cancion.titulo, audio: cancion.audio ?? '', + onda: cancion.onda, duracion: cancion.duracion, ...contexto(cancion) }; @@ -60,6 +61,7 @@ export function aPistaDeVersion(cancion: Cancion, version: VersionMusical): Pist cancionSlug: cancion.slug, titulo: `${cancion.titulo} · ${version.nombre}`, audio: version.audio ?? '', + onda: version.onda, duracion: version.duracion, ...contexto(cancion) }; diff --git a/src/lib/player/player.svelte.ts b/src/lib/player/player.svelte.ts index f867141..5c44d72 100644 --- a/src/lib/player/player.svelte.ts +++ b/src/lib/player/player.svelte.ts @@ -33,6 +33,8 @@ export interface PistaReproductor { titulo: string; /** URL pública de la canción completa en calidad de escucha. */ audio: string; + /** Resumen ligero de amplitudes que dibuja el buscador del reproductor. */ + onda?: string; albumTitulo: string; albumSlug: string; portada: string; diff --git a/src/lib/server/catalogo.ts b/src/lib/server/catalogo.ts index 8a20887..ccbb72a 100644 --- a/src/lib/server/catalogo.ts +++ b/src/lib/server/catalogo.ts @@ -115,6 +115,19 @@ function rutaPublica(clave: string | null): string | undefined { return clave ? `/${clave}` : undefined; } +/** + * La onda es un derivado estático de la escucha y comparte su ruta relativa. + * No es una columna editorial: si cambia el audio, `scripts/medios.mjs` vuelve + * a calcularla y conserva esta dirección estable. + */ +function rutaDeOnda(clave: string | null, huella: string | null): string | undefined { + if (!clave?.startsWith('escucha/')) return undefined; + const ruta = `/${clave.replace(/^escucha\//, 'ondas/').replace(/\.[^/.]+$/, '.json')}`; + // La dirección lógica es estable, pero la huella evita que el navegador + // conserve la onda anterior cuando se sustituye el archivo de escucha. + return huella ? `${ruta}?v=${huella.slice(0, 12)}` : ruta; +} + async function cargar(): Promise { /* * Alias de verdad y no la misma tabla dos veces: una canción se une a @@ -217,6 +230,7 @@ async function cargar(): Promise { .select({ cancionId: cancionAudio.cancionId, clave: medio.clave, + huella: medio.sha256, duracion: medio.duracionS }) .from(cancionAudio) @@ -383,6 +397,7 @@ async function cargar(): Promise { slug: v.fila.slug, nombre: v.fila.versionNombre ?? '', audio: rutaPublica(escuchaVersion?.clave ?? null), + onda: rutaDeOnda(escuchaVersion?.clave ?? null, escuchaVersion?.huella ?? null), duracion: v.fila.duracionS ?? escuchaVersion?.duracion ?? undefined, nota: v.fila.versionNota ?? undefined, interpretes: interpretesVersion.map((i) => ({ @@ -425,6 +440,7 @@ async function cargar(): Promise { album: albumSlug ?? null, numero: fila.numero, audio: rutaPublica(escucha?.clave ?? null), + onda: rutaDeOnda(escucha?.clave ?? null, escucha?.huella ?? null), duracion: fila.duracionS ?? escucha?.duracion ?? undefined, ano: fila.ano ?? undefined, letra: suyas.filter((p) => p.rol === 'letra').map((p) => p.nombre), diff --git a/src/lib/types.ts b/src/lib/types.ts index f591440..bcdbd8d 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -61,6 +61,8 @@ export interface VersionMusical { * que el archivo privado entregado tras la compra. */ audio?: string; + /** URL del resumen de amplitudes usado para dibujar la forma de onda. */ + onda?: string; /** Duracion en segundos. */ duracion?: number; /** Nota breve sobre el arreglo. */ @@ -158,6 +160,8 @@ export interface Cancion { * forman parte del contrato que llega al navegador. */ audio?: string; + /** URL del resumen de amplitudes generado a partir del audio de escucha. */ + onda?: string; /** Duracion en segundos. Se calcula desde "m:ss" en el frontmatter. */ duracion?: number; /** Ano de composicion o registro. */ diff --git a/static/ondas/cupido-sin-flechas/01-amame-esta-noche-nada-mas.json b/static/ondas/cupido-sin-flechas/01-amame-esta-noche-nada-mas.json new file mode 100644 index 0000000..0bcc110 --- /dev/null +++ b/static/ondas/cupido-sin-flechas/01-amame-esta-noche-nada-mas.json @@ -0,0 +1 @@ +{"perfil":"picos-128-v1","picos":[30,118,122,135,153,137,136,119,178,173,166,188,160,154,160,210,182,198,222,210,190,187,169,188,150,132,205,213,206,188,190,179,121,215,246,248,240,241,241,247,248,239,226,233,237,239,244,188,171,169,211,240,234,244,239,243,236,227,230,240,255,255,255,254,247,238,241,240,243,246,238,246,233,241,243,236,233,184,186,166,151,226,243,247,243,243,245,243,250,244,247,249,249,235,223,200,212,189,242,255,241,244,248,255,255,255,228,186,154,168,182,142,141,187,199,195,188,167,182,243,251,245,255,248,254,238,98,22]} diff --git a/static/ondas/cupido-sin-flechas/02-cuando-tus-manos-me-tocan.json b/static/ondas/cupido-sin-flechas/02-cuando-tus-manos-me-tocan.json new file mode 100644 index 0000000..fb05d1b --- /dev/null +++ b/static/ondas/cupido-sin-flechas/02-cuando-tus-manos-me-tocan.json @@ -0,0 +1 @@ +{"perfil":"picos-128-v1","picos":[119,116,107,138,166,131,161,183,164,205,133,200,208,203,219,179,193,212,228,217,221,222,203,225,221,224,219,214,205,195,213,225,236,219,232,230,230,243,237,234,242,241,239,238,237,244,244,233,215,208,175,220,220,208,215,235,212,232,234,238,233,233,238,216,195,237,221,223,227,244,218,208,213,255,245,250,253,248,252,255,253,243,242,248,255,255,255,252,250,247,249,249,255,211,199,158,214,190,165,226,188,210,213,212,253,250,250,247,245,246,250,255,252,247,240,255,247,237,187,223,202,140,122,73,162,131,74,16]} diff --git a/static/ondas/cupido-sin-flechas/03-inventario-a-los-56.json b/static/ondas/cupido-sin-flechas/03-inventario-a-los-56.json new file mode 100644 index 0000000..8da4fc3 --- /dev/null +++ b/static/ondas/cupido-sin-flechas/03-inventario-a-los-56.json @@ -0,0 +1 @@ +{"perfil":"picos-128-v1","picos":[179,193,172,182,178,164,205,191,157,211,199,185,217,204,187,218,165,231,213,159,211,201,171,203,209,167,197,215,215,227,255,227,234,255,233,255,227,227,255,231,238,241,217,227,242,235,249,250,234,240,246,224,232,246,228,250,255,228,247,237,252,255,255,242,251,241,240,241,224,238,243,238,232,244,249,248,236,250,236,255,230,228,241,244,234,241,237,222,219,235,232,235,187,195,160,142,153,147,163,103,172,175,156,212,212,188,225,220,214,232,219,214,231,219,225,230,230,222,207,173,164,147,167,164,138,100,62,29]} diff --git a/static/ondas/cupido-sin-flechas/04-no-me-mientas.json b/static/ondas/cupido-sin-flechas/04-no-me-mientas.json new file mode 100644 index 0000000..b7ed704 --- /dev/null +++ b/static/ondas/cupido-sin-flechas/04-no-me-mientas.json @@ -0,0 +1 @@ +{"perfil":"picos-128-v1","picos":[56,100,97,80,46,178,95,168,104,165,149,100,130,71,54,147,140,79,179,203,188,207,162,214,192,212,171,188,177,178,225,218,212,231,197,244,210,234,224,226,220,240,220,231,215,220,213,178,186,186,184,188,190,213,184,225,255,243,242,194,253,237,227,183,154,152,146,221,229,232,229,247,251,231,231,229,236,241,238,255,245,255,229,232,226,229,238,250,255,255,229,221,209,223,218,245,252,232,245,255,255,247,216,241,247,231,126,219,243,243,218,248,250,234,169,141,138,168,181,199,232,187,177,166,101,43,20,4]} diff --git a/static/ondas/cupido-sin-flechas/05-pague-por-una-noche-de-amor.json b/static/ondas/cupido-sin-flechas/05-pague-por-una-noche-de-amor.json new file mode 100644 index 0000000..063c4e4 --- /dev/null +++ b/static/ondas/cupido-sin-flechas/05-pague-por-una-noche-de-amor.json @@ -0,0 +1 @@ +{"perfil":"picos-128-v1","picos":[112,155,195,193,185,180,169,142,181,185,161,145,199,201,202,220,213,205,203,210,211,207,217,217,214,209,215,206,207,217,226,222,211,237,225,226,229,242,233,227,238,223,224,237,235,238,222,230,220,216,222,215,227,215,226,214,227,219,229,227,237,243,233,240,242,241,240,244,249,237,243,239,245,246,246,247,249,246,246,247,248,243,246,219,175,162,170,191,186,210,255,252,243,253,255,255,255,203,186,149,182,182,156,220,243,249,242,255,246,244,255,242,242,241,245,244,244,252,250,252,254,254,255,255,245,201,132,54]} diff --git a/static/ondas/cupido-sin-flechas/06-para-que-recordar.json b/static/ondas/cupido-sin-flechas/06-para-que-recordar.json new file mode 100644 index 0000000..da28c40 --- /dev/null +++ b/static/ondas/cupido-sin-flechas/06-para-que-recordar.json @@ -0,0 +1 @@ +{"perfil":"picos-128-v1","picos":[137,198,191,223,215,198,130,211,218,185,225,232,210,236,219,208,237,213,213,232,221,218,171,186,226,220,204,214,217,229,236,220,208,231,219,222,218,224,221,227,212,203,222,208,212,218,249,250,245,215,234,231,215,229,241,219,233,234,232,220,220,221,224,227,211,215,218,151,142,177,228,212,234,238,231,221,232,221,230,241,227,222,242,236,247,247,227,205,245,224,236,154,220,255,248,255,237,246,248,223,251,255,228,249,247,255,255,246,233,232,254,242,255,255,244,250,218,234,247,240,241,249,213,198,246,242,236,128]} diff --git a/static/ondas/cupido-sin-flechas/07-prometiste.json b/static/ondas/cupido-sin-flechas/07-prometiste.json new file mode 100644 index 0000000..47872be --- /dev/null +++ b/static/ondas/cupido-sin-flechas/07-prometiste.json @@ -0,0 +1 @@ +{"perfil":"picos-128-v1","picos":[99,189,202,206,238,255,190,192,154,191,133,166,176,150,177,196,151,198,152,200,223,179,224,208,204,212,211,196,215,195,218,221,225,219,211,237,244,253,216,229,218,225,228,230,224,223,240,226,228,232,219,220,213,206,230,211,214,225,211,223,220,229,228,211,224,235,212,236,226,219,241,227,234,241,232,236,238,248,253,255,235,244,238,250,249,239,241,240,255,230,237,255,249,245,246,243,247,253,244,251,243,233,255,255,245,235,238,249,251,249,255,253,250,237,247,247,240,229,217,223,206,176,213,238,255,225,252,159]} diff --git a/static/ondas/cupido-sin-flechas/08-quedate.json b/static/ondas/cupido-sin-flechas/08-quedate.json new file mode 100644 index 0000000..2f73779 --- /dev/null +++ b/static/ondas/cupido-sin-flechas/08-quedate.json @@ -0,0 +1 @@ +{"perfil":"picos-128-v1","picos":[79,159,200,207,194,227,217,208,163,190,173,141,174,165,153,166,179,174,170,145,185,187,167,201,199,238,228,215,220,219,206,229,215,206,219,221,237,211,222,210,217,224,204,185,206,195,181,205,201,185,197,203,208,199,204,228,228,202,234,237,244,237,226,236,238,224,241,237,221,238,232,246,221,232,226,233,247,243,231,229,223,236,239,242,209,239,218,230,230,225,219,233,226,216,186,123,240,255,255,247,233,221,228,230,234,249,245,247,242,236,250,251,238,253,255,255,231,248,245,255,255,255,241,249,241,223,229,70]} diff --git a/static/ondas/cupido-sin-flechas/09-ton-silence-me-libere.json b/static/ondas/cupido-sin-flechas/09-ton-silence-me-libere.json new file mode 100644 index 0000000..b89cf5f --- /dev/null +++ b/static/ondas/cupido-sin-flechas/09-ton-silence-me-libere.json @@ -0,0 +1 @@ +{"perfil":"picos-128-v1","picos":[87,78,97,82,82,108,84,148,132,146,122,163,128,147,103,160,134,173,137,172,150,155,131,189,185,180,151,188,178,211,204,245,228,238,204,225,230,231,210,231,203,219,199,216,208,206,206,203,210,200,197,214,210,189,212,217,215,197,216,210,221,210,221,218,223,199,215,191,212,145,226,241,238,231,245,239,238,220,231,227,241,255,255,250,247,232,248,255,251,250,250,236,236,231,240,233,239,251,255,250,255,248,254,253,251,240,247,248,253,255,255,237,240,242,249,230,242,249,255,240,253,236,97,71,73,59,49,22]} diff --git a/static/ondas/cupido-sin-flechas/10-tus-malas-maneras.json b/static/ondas/cupido-sin-flechas/10-tus-malas-maneras.json new file mode 100644 index 0000000..9148d8a --- /dev/null +++ b/static/ondas/cupido-sin-flechas/10-tus-malas-maneras.json @@ -0,0 +1 @@ +{"perfil":"picos-128-v1","picos":[126,163,154,185,136,164,108,60,152,185,150,205,175,206,134,203,175,171,176,191,197,170,194,106,213,187,225,196,199,189,192,189,234,255,255,248,255,250,249,254,209,216,144,163,119,159,137,159,110,117,145,218,220,249,234,244,218,214,195,194,240,231,181,244,245,242,238,232,198,231,186,181,50,138,173,144,170,159,141,83,172,157,172,201,237,251,255,255,255,238,248,248,233,179,201,146,84,166,127,112,174,185,154,253,255,250,230,240,234,246,215,213,229,242,238,233,240,242,235,225,220,232,207,85,102,75,137,98]} diff --git a/static/ondas/singles/dorina.json b/static/ondas/singles/dorina.json new file mode 100644 index 0000000..d5de3c5 --- /dev/null +++ b/static/ondas/singles/dorina.json @@ -0,0 +1 @@ +{"perfil":"picos-128-v1","picos":[95,134,161,169,170,180,182,170,172,163,174,135,176,187,194,201,206,207,214,185,149,200,175,165,182,194,186,164,195,186,170,182,203,172,202,191,196,200,195,194,216,241,215,227,212,221,213,213,242,211,225,227,218,217,229,233,220,210,217,214,211,216,231,225,225,244,255,255,216,208,207,215,219,214,206,204,222,217,195,224,225,214,199,197,204,198,203,207,202,219,216,194,216,231,226,237,232,241,238,248,255,232,240,247,245,241,255,241,238,246,252,239,239,233,255,255,244,255,224,247,245,234,233,226,205,230,244,128]}