Generate and display song waveforms

codex/redisenio-dominio-musical
dev 4 weeks ago
parent 1f86097d97
commit 7f1b0e8c75

@ -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);
});
});

@ -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/
<la misma estructura, siempre con extensión .mp3>
static/ondas/
<la misma estructura, siempre con extensión .json>
```
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:

@ -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
});

@ -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

@ -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]);
});
});

@ -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<number[]>([]);
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 @@
<div class="progreso">
<span class="tiempo" aria-hidden="true">{segundosADuracion(mostrado)}</span>
<input
type="range"
class="pista"
style="--avance: {porcentaje}%"
min="0"
max={maximo || 1}
step="0.5"
value={mostrado}
disabled={!activa || maximo === 0}
oninput={alMover}
onchange={alSoltar}
aria-label="Posición de reproducción"
aria-valuetext="{segundosADuracion(mostrado)} de {segundosADuracion(maximo)}"
/>
<div class="carril" class:con-onda={Boolean(onda)} class:apagado={!activa || maximo === 0}>
{#if picos.length > 0}
<svg
class="onda"
data-forma-onda
viewBox={`0 0 ${anchoOnda} 100`}
preserveAspectRatio="none"
style="--avance: {porcentaje}%"
aria-hidden="true"
>
<path class="onda-pendiente" d={trazo}></path>
<path class="onda-escuchada" d={trazo}></path>
</svg>
{:else if onda}
<!-- Reserva el alto mientras llega el JSON y sirve de alternativa si falla. -->
<span class="reserva" aria-hidden="true"></span>
{/if}
<input
type="range"
class="pista"
style="--avance: {porcentaje}%"
min="0"
max={maximo || 1}
step="0.5"
value={mostrado}
disabled={!activa || maximo === 0}
oninput={alMover}
onchange={alSoltar}
aria-label="Posición de reproducción"
aria-valuetext="{segundosADuracion(mostrado)} de {segundosADuracion(maximo)}"
/>
</div>
<span class="tiempo" aria-hidden="true">{segundosADuracion(maximo)}</span>
</div>
@ -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;
}
}
</style>

@ -111,13 +111,18 @@
{/if}
<div class="datos">
<p class="linea">
<a class="titulo" href={resolve('/(sitio)/canciones/[slug]', { slug: pista.cancionSlug })}>
<a
class="titulo"
href={resolve('/(sitio)/canciones/[slug]', { slug: pista.cancionSlug })}
>
{pista.titulo}
</a>
<span class="calidad">Calidad de escucha</span>
</p>
<span class="contexto muted">
<a href={resolve('/(sitio)/albumes/[slug]', { slug: pista.albumSlug })}>{pista.albumTitulo}</a>
<a href={resolve('/(sitio)/albumes/[slug]', { slug: pista.albumSlug })}
>{pista.albumTitulo}</a
>
{#if pista.estiloNombre}
<span aria-hidden="true">&middot;</span>
<a href={resolve('/(sitio)/musica/[estilo]', { estilo: pista.estiloSlug })}
@ -185,6 +190,7 @@
<BarraProgreso
tiempo={reproductor.tiempo}
duracion={reproductor.duracion}
onda={pista.onda}
onbuscar={(s) => reproductor.buscar(s)}
/>
</div>

@ -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)
};

@ -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;

@ -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<Catalogo> {
/*
* Alias de verdad y no la misma tabla dos veces: una canción se une a
@ -217,6 +230,7 @@ async function cargar(): Promise<Catalogo> {
.select({
cancionId: cancionAudio.cancionId,
clave: medio.clave,
huella: medio.sha256,
duracion: medio.duracionS
})
.from(cancionAudio)
@ -383,6 +397,7 @@ async function cargar(): Promise<Catalogo> {
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<Catalogo> {
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),

@ -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. */

@ -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]}

@ -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]}

@ -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]}

@ -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]}

@ -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]}

@ -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]}

@ -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]}

@ -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]}

@ -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]}

@ -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]}

@ -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]}
Loading…
Cancel
Save

Powered by TurnKey Linux.