/** * Coloca el audio que llega desde el panel y prepara lo que el navegador no * puede preparar. * * El panel de contenido escribe archivos en el repositorio, así que subir el * máster desde ahí es fácil. Lo que no puede hacer es generar la copia protegida * completa de menor calidad ni medir la duración: eso es ffmpeg. * * Qué hace: * * 1. Si su clave de audio apunta a la bandeja (`subidas/…`), mueve el archivo * a la carpeta que le toca —la del disco, o `singles/`— con el nombre de * la convención, y reescribe la clave en el Markdown. * 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. 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: * `npm run medios`. * * Sin ffmpeg no falla: avisa de lo que no ha podido hacer y sigue. Un entorno * de despliegue no lo necesita si recibe ya preparado el almacén de escuchas. */ import { spawnSync } from 'node:child_process'; 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, PERFIL_ONDA, argumentosDeEscucha, argumentosDeOnda, claveDeEscucha, claveDeOnda, resumirPicos } from '../src/lib/procesado-audio.mjs'; export { argumentosDeEscucha, argumentosDeOnda, claveDeEscucha, claveDeOnda, resumirPicos } from '../src/lib/procesado-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, 'media/escucha'); const ONDAS = path.join(RAIZ, 'static/ondas'); /** Carpeta donde deja el panel lo que se sube; nada se queda aquí. */ const BANDEJA = 'subidas'; const EXTENSIONES_AUDIO = new Set(['.wav', '.mp3', '.m4a', '.flac', '.aac', '.ogg']); /** Perfil audible pero claramente inferior al archivo entregado al comprar. */ const avisos = []; const hechos = []; function ejecutar(programa, argumentos) { const r = spawnSync(programa, argumentos, { encoding: 'utf8' }); if (r.error || r.status !== 0) return undefined; return r.stdout.trim(); } /** 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) { const salida = ejecutar('ffprobe', [ '-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', archivo ]); const segundos = Number(salida); return Number.isFinite(segundos) && segundos > 0 ? segundos : undefined; } /** Mismo criterio que `aSlug` en el esquema: sin tildes, minúsculas y guiones. */ export function aSlug(texto) { return String(texto) .normalize('NFD') .replace(/\p{Diacritic}/gu, '') .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, ''); } export function comoDuracion(segundos) { const total = Math.round(segundos); return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`; } /** * Una clave está en la bandeja si su primer tramo es `subidas`. Se admite con * barra inicial porque el panel puede escribirla así. */ export function enLaBandeja(clave) { return clave.replace(/^\//, '').split('/')[0] === BANDEJA; } /** * Dónde debería vivir el audio de un tema, según la convención: agrupado por * disco, numerado como la pista, y `singles/` para los temas sueltos. */ export function claveCanonica(datos, slug, clave, sufijo) { const carpeta = datos.album ? aSlug(datos.album) : 'singles'; const numero = datos.album && datos.numero ? `${String(datos.numero).padStart(2, '0')}-` : ''; const extension = path.extname(clave).toLowerCase() || '.mp3'; return `${carpeta}/${numero}${slug}${sufijo}${extension}`; } /** Mueve el máster dentro del almacén privado, sin pisar nada. */ function colocar(claveVieja, claveNueva) { const origen = path.join(MASTERS, claveVieja.replace(/^\//, '')); const destino = path.join(MASTERS, claveNueva); if (!fs.existsSync(origen)) { avisos.push(`${claveVieja}: el panel lo declara pero no está en media/audio/.`); return false; } if (fs.existsSync(destino)) { avisos.push(`${claveNueva}: ya existe, no se pisa. Borra uno de los dos y vuelve a lanzarlo.`); return false; } fs.mkdirSync(path.dirname(destino), { recursive: true }); fs.renameSync(origen, destino); hechos.push(`colocado ${claveVieja} -> ${claveNueva}`); return true; } /** Genera la canción protegida de escucha si falta o si el máster es más nuevo. */ function generarEscucha(clave) { const master = path.join(MASTERS, clave); const escucha = path.join(ESCUCHAS, claveDeEscucha(clave)); if (!fs.existsSync(master)) { avisos.push(`${clave}: no hay máster en media/audio/, no se puede generar la escucha.`); return; } if (fs.existsSync(escucha) && fs.statSync(escucha).mtimeMs >= fs.statSync(master).mtimeMs) { return; } if (!hayFfmpeg) { avisos.push(`${clave}: falta la copia de escucha y no hay ffmpeg para generarla.`); return; } fs.mkdirSync(path.dirname(escucha), { recursive: true }); const hecho = ejecutar('ffmpeg', argumentosDeEscucha(master, escucha)); if (hecho === undefined) { avisos.push(`${clave}: ffmpeg no pudo generar la escucha completa.`); return; } hechos.push( `escucha ${claveDeEscucha(clave)} (${PERFIL_ESCUCHA.bitrateKbps} kbps, ${PERFIL_ESCUCHA.id})` ); } /** `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 []; return fs.readdirSync(directorio, { withFileTypes: true }).flatMap((entrada) => { if (entrada.name === BANDEJA) return []; const absoluta = path.join(directorio, entrada.name); if (entrada.isDirectory()) return recorrerMasters(absoluta); return EXTENSIONES_AUDIO.has(path.extname(entrada.name).toLowerCase()) ? [absoluta] : []; }); } /** * Sustituye el valor de un campo escalar del frontmatter sin tocar el resto del * archivo. Se hace por líneas a propósito: volcar el YAML entero reordenaría * campos y perdería el estilo de las comillas. */ function cambiarCampo(lineas, campo, valor, dentroDeVersion) { const sangria = dentroDeVersion ? /^ {4}/ : /^/; const patron = new RegExp(`^(${dentroDeVersion ? ' {4}' : ''})${campo}:\\s`); const i = lineas.findIndex((l) => patron.test(l) && sangria.test(l)); if (i === -1) return false; lineas[i] = lineas[i].replace(new RegExp(`^(\\s*${campo}:\\s*).*$`), `$1${valor}`); return true; } /** Inserta un campo justo detrás de otro, con su misma sangría. */ function insertarTras(lineas, referencia, campo, valor) { const i = lineas.findIndex((l) => new RegExp(`^\\s*${referencia}:\\s`).test(l)); if (i === -1) return false; const sangria = lineas[i].match(/^\s*/)[0]; lineas.splice(i + 1, 0, `${sangria}${campo}: '${valor}'`); return true; } function procesar(archivo) { const slug = path.basename(archivo, '.md'); const bruto = fs.readFileSync(path.join(CANCIONES, archivo), 'utf8'); const corte = bruto.indexOf('\n---', 4); if (!bruto.startsWith('---') || corte === -1) return; const cabecera = bruto.slice(4, corte); const datos = leerYaml(cabecera) ?? {}; const lineas = cabecera.split('\n'); let tocado = false; // --- El máster principal. if (typeof datos.audio === 'string') { let clave = datos.audio.replace(/^\//, ''); if (enLaBandeja(clave)) { const nueva = claveCanonica(datos, slug, clave, ''); if (colocar(clave, nueva)) { cambiarCampo(lineas, 'audio', nueva, false); clave = nueva; tocado = true; } } generarEscucha(clave); generarOnda(clave); if (!datos.duracion && hayFfmpeg) { const segundos = medir(path.join(MASTERS, clave)); if (segundos && insertarTras(lineas, 'audio', 'duracion', comoDuracion(segundos))) { hechos.push(`duración ${slug}: ${comoDuracion(segundos)}`); tocado = true; } } } // --- Las alternativas musicales, con el mismo criterio. for (const version of Array.isArray(datos.versiones) ? datos.versiones : []) { if (typeof version?.audio !== 'string') continue; let clave = version.audio.replace(/^\//, ''); if (enLaBandeja(clave)) { const id = version.id ?? aSlug(version.nombre ?? 'version'); const nueva = claveCanonica(datos, slug, clave, `-${id}`); if (colocar(clave, nueva)) { const i = lineas.findIndex((l) => l.includes(version.audio)); if (i !== -1) { lineas[i] = lineas[i].replace(version.audio, nueva); clave = nueva; tocado = true; } } } generarEscucha(clave); generarOnda(clave); } if (tocado) { fs.writeFileSync( path.join(CANCIONES, archivo), `---\n${lineas.join('\n')}${bruto.slice(corte)}` ); } } /** * Solo actúa cuando se le llama desde la línea de órdenes. Importado —para * comprobar la convención de nombres— no toca ningún archivo. */ export function principal() { if (fs.existsSync(CANCIONES)) { for (const archivo of fs.readdirSync(CANCIONES).filter((f) => f.endsWith('.md'))) { procesar(archivo); } } for (const master of recorrerMasters()) { 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 // siempre sin que nadie lo notara. const bandeja = path.join(MASTERS, BANDEJA); if (fs.existsSync(bandeja)) { for (const suelto of fs.readdirSync(bandeja)) { avisos.push(`${BANDEJA}/${suelto}: no lo reclama ninguna canción. ¿Falta su ficha?`); } } for (const linea of hechos) console.log(` ${linea}`); for (const aviso of avisos) console.warn(` aviso: ${aviso}`); if (hechos.length === 0 && avisos.length === 0) console.log(' medios al día.'); } if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { principal(); }