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