Ejemplo en NodeJS: normalizando audio de vídeos con FFMpeg (y TDD con Mocha+Chai)

En este tutorial vamos a ver un ejemplo de desarrollo con TDD en NodeJS, empleando Mocha y Chai. Lo usaremos para normalizar (ajustar volumen) el audio de unos vídeos con la librería FFMpeg

0. Índice de Contenidos

1. Entorno

Este tutorial está escrito usando el siguiente entorno:

  • Acer Aspire One 753 (2 Ghz Intel Celeron, 4GB DDR2)
  • Ubuntu 15.10
  • NPM 1.4.21
  • NodeJS v0.10.25
  • FFMpeg 2.7.3

Puedes descargar el código de este tutorial en https://github.com/4lberto/VideoAudioNormalizer

2. Problema a Resolver

En Autentia estamos grabando cursos on-line de nuestras especialidades. Para ello, usamos normalmente la modalidad de screencast que grabamos en una cámara especial de grabación para que el audio tenga cierta calidad: sin reverberaciones ni interferencias externas. Además empleamos un buen micrófono de grabación.

El audio de los vídeos es fantástico (salvo por el narrador jeje), pero quizá el nivel no sea el adecuado: posiblemente es demasiado bajo, lo que puede causar molestias a los alumnos, que deberán ajustar el audio de su sistema en exclusiva para estos vídeos.

El nivel de audio se mide en decibelios (dB). Al ser un fichero del que se lee, el nivel final dependerá de cómo lo reproduzca el usuario. Por eso, se establecen valores negativos, que luego serán amplificados.

El objetivo será aumentar el volumen de cada vídeo hasta cierto nivel establecido. Para complicarlo un poco más y ampliar nuestro campo de trabajo, vamos a hacerlo en NodeJS.

Para ello se seguirá la siguiente lógica:

  1. Obtener el volumen máximo del vídeo, por ejemplo -5.7dB.
  2. Aumentar el volumen máximo hasta llegar a 0.0dB. Por tanto, subir ese vídeo en 5.7dB positivos.

Si algún ingeniero de sonido lee esto seguro que me mata, pero para las grabaciones que hemos hecho y lo que buscamos no es suficiente. También podría tomar el volumen medio y subirlo hasta un nivel determinado, pongamos -20.0dB, pero no quería tampoco hacer “clipping“.

No obstante, el programa está disponible en GitHub, así que puedes hacer un fork y tunearlo a tu gusto.

3. FFMpeg

Para tratar el vídeo nos hace falta una biblioteca de tratamiento de vídeo y audio, como es FFMpeg. Puede ser descargada en: https://www.ffmpeg.org/.

Esta herramienta nos va a permitir analizar los vídeos y transformarlos con las instrucciones que indiquemos para alterar el volumen del audio y que lo amplifique. Vamos a ver cómo usarlo.

3.1. Instalando FFMpeg

FFMpeg es muy fácil de instalar. O bien lo podemos descargar de su página oficial, eligiendo el sistema operativo de nuestro ordenador: Mac, Windows o varias distribuciones de Linux, o podemos emplear el gestor de paquetes de nuestro sistema operativo.

Si te lo vas a descargar es muy fácil: se baja un fichero comprimir en cuyo interior está el ejecutable que se utiliza, ¡nada más! Una vez se tiene el ejecutable lo podemos referenciar en el path del sistema para que al escribir “ffmpeg” en cualquier lado, el sistema responda.

Como estoy usando Ubuntu 15.10 voy a usar el gestor synaptic. No puede ser más fácil.

Esperamos un poco y ya lo tenemos instalado. Sin ningún problema :).

El gestor de paquetes lo mete en el path, así que probamos directamente a escribir en un terminal “ffmpeg” y vemos que funciona sin problemas:

El resultado debería ser algo de este estilo:

3.2. Aplicando FFMpeg a los vídeos

FFMpeg tiene multitud de opciones entre las que tuve que navegar para encontrar lo que quería. Básicamente quería dos cosas:

  • Obtener el valor del nivel de audio máximo para el vídeo.
  • Alterar el valor del vídeo.

Para conocer el valor del nivel máximo de audio de un vídeo, ejecutaremos en nuestra línea de comandos la siguiente instrucción:

Donde INPUT.mp4 es el vídeo del cual queremos conocer el volumen máximo. La salida es bastante extensa, y puede llevar un rato en procesarse, dependiendo del tamaño y calidad del vídeo. Lo que nos interesa es la parte final:

Y específicamente nos interesan estas líneas.

Podemos ver el volumen medio (mean_volume) y el máximo (max_volume). En el vídeo que hemos utilizado es de -10.7dB, así que subiremos el volumen general 10.7dB para que el volumen máximo final sea de 0.0dB. O si lo prefieres puedes acercarte algo menos a 0.0dB, como por ejemplo dejándolo en -5.0dB.

También si quieres puede usar el volumen medio… aunque correrías el riesgo de cortar alguna señal por volumen máximo…

Para la segunda parte, que sería ordenar subir 10.7dB positivos al volumen del audio del vídeo, tenemos este comando:

Donde INPUT.mp4 es el fichero al que le vamos a aplicar la transformación, e INPUT_NORMALIZED.mp4 es el fichero de salida.

Esto es todo lo que debemos conocer de FFMpeg para hacer nuestro programa para normalizar audio de nuestros vídeos.

Como podrás suponer, la dificultar radica en ejecutar FFMpeg, recoger el resultado de salida y aplicar el segundo comando correctamente. Vamos a ver cómo hacemos todo esto en NodeJs.

4. Proyecto en NodeJS

Vamos con lo más interesante, porque lo anterior es sólo un pretexto para tener algo que hacer con nodeJS :).

Deberías tener instalado NodeJS y NPM en tu sistema. Ya hay otros tutoriales en los que explicamos cómo hacerlo. Por ejemplo este en el que hablo de Polymer y que necesitamos nodeJS y NPM para montar el entorno: Introducción a Polymer. Por tanto no voy a repetir aquí la instalación. Asumimos que los tienes instalados, listos para usar, al menos en las versiones que indico en el apartado Entorno.

4.1. Creación de la estructura

Comencemos creando el proyecto en un directorio determinado que hemos reservado en nuestro ordenador.

Ahora iniciamos el package.json con npm. Aquí residirá la metainformación del proyecto de NodeJS y las dependencias, así que tómatelo en serio 🙂

Completa la información que pide: nombres, versiones, GitHub…

Ahora debemos instalar las dependencias, que serán:

  • Chai 3.4.1
  • Mocha 2.3.4
  • fluent-ffmpeg 2.0.1

Las dos primeras son en tiempo de desarrollo y la tercera forma parte del programa. Para instalarlas haremos:

El fichero package.json debería quedar algo así:

El siguiente paso consiste en crear un directorio “libs” donde residirán los módulos auxiliares de la aplicación, y otro “test” donde estarán los ficheros de test que se ejecutarán con Mocha. Muy fácil:

Finalmente vamos a instalar Mocha para que pueda ejecutar los test desde línea de comandos. Efectivamente, si escribimos “mocha” en nuestra shell, debería funcionar, sino lo hace, lo tenemos que instalar con NPM, haciendo uso de la opción “-g” para indicar que es una instalación global a todo el sistema.

Ahora si escribimos mocha, y tenemos el directorio test creado pero vacío, debería responder con esta salida:

4.2. Comienzo del desarrollo

Antes de comenzar con el desarrollo del programa, vamos a ver en primer lugar una característica muy importante de NodeJS que va a influir determinantemente en cómo se desarrolla: los módulos.

4.2.1. El sistema de Módulos de NodeJS

NodeJS está basado en módulos. Los módulos, sin entrar mucho en detalle, son conjuntos de funciones agrupadas en un único archivo. De este modo, dentro de nuestro fichero de NodeJS podemos cargar otros módulos sobre los que nos podemos apoyar. Y por supuesto, también podemos crear nuestros propios módulos.

Podrás imaginar entonces la importancia de NPM y la gestión de dependencias en NodeJS: puedes instalar cualquier módulo y hacer uso de él muy fácilmente. ¿Cómo lo hago? Es muy fácil: utilizando la función require(“nombreMódulo”). Así es cómo cargamos el módulo nativo FS (FileSystem) de NodeJS:

Con estas sencillas instrucciones (vete acostumbrado al mundo de los callback), hemos logrado consultar si el archivo “/etc/hosts” existe en nuestro sistema.

Como puedes ver, se asigna el módulo a la variable “fs” y luego se hace uso de ella. El parámetro de require(“fs”) indica el módulo que se quiere cargar. Así, se puede indicar por el nombre directamente o por el fichero. Algunos módulos forman parte del core de NodeJS, otros están instalados globalmente, otros son del propio proyecto, y finalmente otros son simplemente ficheros que cargar. Por tanto, la notación soporta parámetros del estilo:

La función require realiza una búsqueda según unos ciertos criterios.

¿Cómo puedo crear mis propios módulos? Aquí es donde vamos… Simplemente creando un fichero de JavaScript que contiene las funciones. Pero hay una cosa más… tienes que exportar esas funciones para que sean accesibles desde el exterior. No todas las funciones y contenidos son expuestos cuando son importados.

En todo módulo hay un objeto implícito llamado “module”, que referencia al objeto que representa el módulo actual. No es una referencia global al programa, sino que es algo del propio módulo que estamos creando. Dentro de las propiedades del objeto “module” hay varias, como referencia a las dependencias, si está cargado o no, carga de otros módulos, pero sobre todo nos interesa “exports”. Con module.exports indicamos qué funciones pueden ser empleadas por los módulos que hacen la carga de éste. Como es muy usado, tiene un alias que es simplemente “exports”. Lo vemos mejor en código.

Vamos a crear un módulo de operaciones aritméticas. La típica calculadora con suma y resta:

Y ya está… como module.exports tiene una variable exports (es decir exports = module.exports), lo podríamos haber reducido a:

Imagina que guardamos este código en el fichero “calculadora.js”. ¿Cómo lo usamos en otro módulo?

Básicamente esto es lo que debemos saber acerca de los módulos. Hay otros detalles, pero son más avanzados. Sabiendo esto, tenemos suficiente para adentrarnos en la aplicación.

4.2.2. Organización del código

Con este mecanismo de módulos, se me ha ocurrido crear la siguiente estructura:

  • Fichero principal ejecutable: audioNormalized.js
  • Directorio para archivos auxiliares:libs
    • Fichero para funciones principales: audioLib.js
    • Fichero para funciones auxiliares: utils.js
  • Directorio para test(TDD):tests
    • Fichero de test para funciones principales: audioLibTest.js
    • Fichero de test para funciones auxiliares: utilsTest.js

Quizá no sea la mejor opción de organizar todo, pero sí que me ha permitido aplicar un poco de TDD para la construcción. La aplicación es relativamente sencilla, así que no hay problemas. En código:

4.3. Aplicando TDD

Es hora de empezar a programar, y para ello vamos a hacer TDD: se trata de hacer primero los test y luego hacer el código que cumple los test. En realidad es algo más elaborado. Puedes echar un ojo a una crítica que hice del libro en el que se basa, y donde explico en qué consiste en esta entrada.

4.3.1. Mocha y Chai

Pero para hacer TDD necesitamos un sistema para hacer test. La parte buena de NodeJS es que hereda los sistemas de test existentes para JavaScript, que además, últimamente está teniendo un empuje enorme.

Para este tutorial me he decantado por Mocha y Chai. Mocha es un framework de testing que da soporte para ejecutar test en Javascript. Chai por su parte es una librería de “asserts”, es decir, facilita expresar los predicados que tienen que cumplir los test. Algo así como que hace sencillo escribir en código “el resultado debe estar entre 5 y 50”.

En secciones anteriores ya hemos explicado cómo instalar Mocha y Chai (via npm). Incluso si hemos instalado globalmente Mocha (npm install -g mocha) disponemos del comando “mocha” en nuestro ordenador.

4.3.2. Comenzando TDD

Comenzamos ejecutando el comando mocha en nuestro terminal en el directorio raíz. Como hemos creado el subdirectorio “test” con dos ficheros vacíos, Mocha encontrará 0 test que ejecutar:

Esto es una buena noticia porque ya tenemos un punto de partida. Vamos a hacer TDD: primero hacemos un tests de una necesidad. Se me ocurre que en primer lugar necesitamos obtener una lista de ficheros de una extensión determinada (vídeos) de un directorio sobre el que actuar. Vamos a expresarlo en un test.

Bueno, en realidad se trata de un test de integración porque tenemos que preparar el entorno. Hay algunas cosas interesantes que nos van a forzar a programar, más allá del test:

  • Que el fichero donde va a estar la función que vamos a testear es utils.js.
  • Que debemos cargar algún fichero de extensión mp4 en el directorio “test”.
  • Que la función se va a llamar “getFilesFromDirWithExtension” y que tiene dos parámetros: el directorio y la extensión.

Ya tenemos el test. Pues lo ejecutamos para ver nuestra primera bandera roja:

He ido demasiado rápido estableciendo el test completo, debería haber ido más despacio, pero nos podríamos eternizar… Vamos a ver qué código hace que se cumpla el test:

Si te fijas, he creado la variable para asignarla a la función y en un segundo paso, en la última línea se ha hecho la asignación a exports, que recuerda, es el equivalente de module.exports, y se usa para que los otros módulos que hagan un require, puedan utilizar la función.

Otra cosa que te llamará la atención es que se hace uso del módulo “fs” de NodeJS, que se emplea para el manejo de archivos. Así podemos obtener con readdirSync todos los archivos de un directorio indicado. Posteriormente lo filtramos con filter para quedarnos con los de extensión deseada.

Ahora ponemos un fichero con extensión .mp4 en el directorio test y ejecutamos Mocha. Esta vez tenemos buenas noticias:

¡Ya tenemos nuestro primer test pasado! Ahora es cuestión de ir añadiendo más y mas test hasta completar la funcionalidad.

No voy a poner el código de todos los test de la parte de utilidades que realicé. Voy a enumerar algunos de ellos simplemente:

  • Que devuelva solamente los ficheros de la extensión indicada.
  • Que tome el valor de la salida de FFmpeg correspondiente al volumen máximo con 1 entero y 1 decimal.
  • Que tome el valor de la salida de FFmpeg correspondiente al volumen máximo con 2 entero y 1 decimal.
  • Que tome el valor de la salida de FFmpeg correspondiente al volumen máximo con 4 entero y 1 decimal.
  • Que dado un valor en dB negativos le invierta el signo.
  • Que dado un valor en dB positivos le invierta el signo.
  • Que dado un nombre de fichero le añada “_NORMALIZED” al final del nombre.

Una vez que ya tenemos las funciones de util.js, que son las básicas, podemos ponernos con las principales del fichero audioLib.js, que básicamente son dos:

  • Recoger el valor de volumen máximo en dB de un vídeo.
  • Aumentar el volumen del audio de un vídeo en la cantidad indicada.

Si ya no lo recuerdas, correspondía a las llamadas de estas dos funciones:

Para modelarlas vamos a utilizar dos métodos diferentes:

  1. Usando el wrapper fluent-ffmpeg para manejar FFMpeg desde NodeJS.
  2. Utilizando la capacidad de NodeJS para lanzar comandos.

Vamos con ello:

4.3.3. Fluent-FFMpeg y Testing de Asincronía.

Se trata de un módulo de NodeJS para poder manejar FFMpeg de una forma amigable. Se puede encontrar en https://github.com/fluent-ffmpeg/node-fluent-ffmpeg. Se instala como una dependencia más. Si recuerdas, la hemos instalado al comienzo.

Es fácil de utilizar, como todo en nodeJS:

El principal problema que nos encontramos es la asincronía de las operaciones. Es decir, como son operaciones de proceso y Javascript es asíncrono, el resultado no se obtiene al final del proceso, sino que se obtiene en un callback al que llama la función cuando acaba. Lo vemos mejor en código:

El código Javascript usando Fluent-FFMpeg para la llamada:

es el siguiente:

Si te fijas en la parte de configuración de callbacks, hay 3: start, error y end, que son llamadas para cada evento. El resultado queda en la parte de on:

Básicamente lo que se ha programado son dos cosas:

  • Coger el texto de la salida que se muestra con el resultado (esta librería lo deja en sterr) y se pasa por la función de utilidades que es capaz de extraer sólo la información de los dB de volumen máximo.
  • Pasarle el valor ya filtrado a una función de callback que operará con ello.

Esta función de callback es fundamental, tanto para el desarrollo del programa como para el test oportuno. Por eso hemos puesto el código primero, para demostrar cómo se trata la asincronía con Mocha.

Si intentamos hacer un assert de Chai llamando a la función, fallará al instante porque Mocha, al ejecutar el código Javascript, llama a la función y continúa la ejecución: no espera a que el proceso asíncrono se ejecute.

Afortunadamente Mocha cuenta con un mecanismo para tratar la asincronía. Vamos a hacer el test.

Accedemos al fichero audioLibTest.js en el directorio test para hacer el test de integración sobre un fichero. A este fichero ya le hemos pasado el ffmpeg manualmente, así que sabemos ya el resultado.

Para tratar la asincronía hacemos uso del apartado “before” dentro de un describe, que tiene que ejecutarse antes de los “it” de Mocha. Además, el before, recibe por parámetro la función reservada “done()” de Mocha, que asegura que esperará a que se termine de ejecutar.

Básicamente lo que hacemos es llamar a la función a testear dentro del “before”, y además incluimos en la llamada una función de callback, preparando una variable “resultText” que es la que se evaluará en los asserts posteriores. Este es el código final:

Como se puede ver, la función callback se limita a recoger el resultado y dejarlo en una variable con un scope al que puedan acceder los asserts de chai. Además, incluye done(), que espera a que concluya el proceso asíncrono, de modo que cuando se ejecutan los bloques “it”, la variable “resultText” ya tiene el valor adecuado y puede ser evaluada.

Para el comando de variación del nivel de audio del vídeo vamos a hacer una llamada directamente al sistema con el módulo nativo de NodeJS “child_process”. Como usaremos la salida y la entrada estándar (stdin/stdout) -lo requiere ffmpeg-. Tenemos que usar la función spawn.

Del mismo modo que antes, se trata de una operación lenta y asíncrona por lo que el resultado se dará en un callback.

Para el comando:

Tendremos el siguiente código:

Si observas, el require de “child_process” tiene un .spawn al final. Esto quiere decir que sólo se quiere importar la función “.spawn”.

Como antes, establecemos unos callback: para la salida estándar (stdout), para la salida de error (stderr) y para cuando se cierre el comando porque acaba (‘close’). De nuevo ponemos ahí la referencia a una función de callback que pasamos por parámetro para procesar.

El Test de integración para ver si ha funcionado es más enrevesado esta vez, porque tiene que procesar el fichero y para comprobar que lo ha hecho bien, tiene que volver a calcular el volumen máximo. ¿Cómo lo encadenamos? De nuevo a base de callbacks:

Si te fijas, ahora el callback es la llamada a la otra función y dentro se introduce de nuevo un callback para sacar el resultado de analizar el volumen máximo. Como los callback se ejecutan al finalizar las operaciones, no hay problemas de sincronización.

Y de nuevo la omnipresente función done(), que provoca que el “before” no termine hasta que se han ejecutado todas las funciones.

4.4. El módulo final

Ya tenemos todas las funciones testeadas y con los test correctos, incluidos los de integración. Ahora queda juntar todo en un módulo principal que pueda ejecutar NodeJS y que contenga las llamadas a los módulos que acabamos de crear.

Básicamente, lo que tiene que hacer es:

  1. Obtener el listado de los ficheros de la extensión indicada a procesar de un directorio.
  2. Calcular el volumen máximo del fichero.
  3. Alterar el fichero para subir el volumen.

Sacar el listado de ficheros es sencillo. Lo hemos hecho antes:

Para mayor utilidad, el directorio (videoDir) y la extensión (videoExtension) las hemos sacado de los parámetros al llamar a la ejecución del fichero con:

El valor 2 y 3 corresponden al parámetro número 3 y 4 de la llamada node. El 0 corresponde a node y el 1 corresponde a audioNormalizer.js.

Una vez tenemos todos los ficheros los recorremos con un bucle y llamamos a las otras dos funciones. Pero ahora veremos que no es tan sencillo.

Como hemos dicho, las funciones de audioLib getMaxdBFromFile y normaliceAudio son funciones que tienen llamadas asíncronas, y por tanto, una vez invocadas, devuelve rápidamente el control al flujo del programa, lanzándose asíncronamente sus procesos y devolviendo en un callback el resultado. ¿Cómo afecta esto al recorrer el array de ficheros de vídeos? De una manera no deseada: se lanzan todos los procesos de cada vídeo en paralelo. Si son 2 o 3 puede ser hasta bueno (aunque FFMpeg hace un muy buen uso de los multiprocesos). Pero si son más, es claramente ineficiente y puede bloquear el ordenador en el que lo estamos ejecutando.

Como hemos visto al hacer los test de integración de cada una de las partes, la clave está en las funciones de Callback. Estas funciones son las que recogen el resultado y aseguran que tenemos un punto en el código en el que hay certeza de que la operación asíncrona lanzada ha finalizado. Así pues tenemos este esquema:

  • Llamada a getMaxdBFromFile para obtener el volumen máximo del vídeo.
    • Callback que recoge el valor y llama a normalizeAudio.
      • Callback sobre el resultado para encolar el siguiente vídeo de la lista, aumentando un contador y volviendo a llamar al proceso.

Sí, estamos haciendo uso de la recursividad. En vez de un bucle o una instrucción map sobre un array, lo que vamos a hacer es mantener un índice que indica sobre qué video del array de vídeos se va a operar. Este índice se altera dentro del callback final, que además vuelve a llamar a la función que lo engloba todo. Lo veremos mejor en el código:

Podemos ver el contador, que se inicializa a 0 para comenzar con el primer vídeo del array. La última instrucción es la llamada inicial a la función recursiva, loopProcessVideos(videos,0) y que desencadena todo el proceso.

Y entre ambos está la definición de la función recursiva. ¿Cómo sabemos que es recursiva? Fácil: tiene dos condicionantes. En primer lugar la condición de parada:

De otro modo estaría ejecutándose de forma infinita. Esta condición comprueba que el índice no se salga del vector. Es decir, que cuando llegue al último finalice el proceso.

Y también:

Que no es otra cosa que la llamada recursiva de nuevo a la propia función pero aumentando un número más el valor el contador o puntero a los vídeos para que procese el siguiente.

Como se puede ver, a través del uso de callbacks y una estructura recursiva, hemos eliminado los problemas de asincronía que surgen si se emplean estructuras que están preparadas únicamente para mecanismos síncronos. Es cierto que también existen otros modos de tratar los problemas de sincronización, como por ejemplo la librería async, pero lo dejamos para otros tutoriales..

5. Ejecución

No he incorporado todo el código en este tutorial porque sería demasiado extenso. Pero puedes hacerte un fork del repositorio de gitHub en el que he alojado la versión final y probarlo por ti mismo (y mejorarlo y adaptarlo a tus necesidades). Lo puedes descargar con:

Tampoco olvides descargar las dependencias. Ejecutando este comando en el directorio raíz:

Y como aparece en el README.md la ejecución es muy sencilla, siempre que tengas instalados los requisitos:

Por ejemplo:

Pero como somos desarrolladores, y hemos empleado tests para el desarrollo, te recomiendo que lo primero que hagas sea pasar los test con Mocha y veas que todo funciona correctamente (espero). Incluso he incorporado un sencillo fichero .mp4 en el directorio test para poder ejecutar los test de integración.

Simplemente ejecutamos:

Deberíamos obtener una salida como la siguiente:

En realidad no están todos los 11 test porque antes de este texto aparecen las trazas del procesamiento de FFMpeg y sería demasiado extenso como para ponerlo aquí.

6.Conclusiones

Hemos utilizado la excusa de la normalización de audio de los vídeos para explorar el mundo de NodeJS y sus particularidades. Como todo buen inicio en una nueva tecnología de programación, se debe comenzar creando un entorno en el que se puedan ejecutar tests para guiar nuestro desarrollo, para lo cual hemos incluido Mocha y Chai. Se ha revisado el sistema de módulos de NodeJS para compartimentar las aplicaciones. También hemos visto las particularidades de NodeJS con los procesos asíncronos, y cómo se pueden testear a través de callbacks y la función done() de Mocha.