TensorFlow.js: Reconocimiento de audio mediante aprendizaje por transferencia

1. Introducción

En este codelab, compilarás una red de reconocimiento de audio y la usarás para controlar un deslizador en el navegador haciendo sonidos. Usarás TensorFlow.js, una biblioteca de aprendizaje automático potente y flexible para JavaScript.

Primero, cargarás y ejecutarás un modelo previamente entrenado que puede reconocer 20 comandos de voz. Luego, con el micrófono, compilarás y entrenarás una red neuronal simple que reconozca tus sonidos y haga que el deslizador se mueva hacia la izquierda o la derecha.

En este codelab, no se abordará la teoría detrás de los modelos de reconocimiento de audio. Si tienes curiosidad, consulta este instructivo.

También creamos un glosario de términos de aprendizaje automático que encontrarás en este codelab.

Qué aprenderás

  • Cómo cargar un modelo de reconocimiento de comandos de voz previamente entrenado
  • Cómo hacer predicciones en tiempo real con el micrófono
  • Cómo entrenar y usar un modelo de reconocimiento de audio personalizado con el micrófono del navegador

¡Empecemos!

2. Requisitos

Para completar este codelab, necesitarás lo siguiente:

  1. Una versión reciente de Chrome o de otro navegador moderno
  2. Un editor de texto que se ejecute localmente en tu máquina o en la Web con servicios como CodePen o Glitch.
  3. Conocimientos sobre HTML, CSS, JavaScript y las Herramientas para desarrolladores de Chrome (o las de tu navegador preferido)
  4. Comprensión conceptual de alto nivel de las redes neuronales (si necesitas una introducción o un repaso, te recomendamos mirar este video de 3blue1brown o este video sobre aprendizaje profundo en JavaScript de Ashi Krishnan)

3. Carga TensorFlow.js y el modelo de audio

Abre index.html en un editor y agrega este contenido:

<html>
  <head>
    <script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs"></script>
    <script src="https://cdn.jsdelivr.net/npm/@tensorflow-models/speech-commands"></script>
  </head>
  <body>
    <div id="console"></div>
    <script src="index.js"></script>
  </body>
</html>

La primera etiqueta <script> importa la biblioteca de TensorFlow.js, y la segunda <script> importa el modelo de Speech Commands previamente entrenado. La etiqueta <div id="console"> se usará para mostrar el resultado del modelo.

4. Predice en tiempo real

A continuación, abre o crea el archivo index.js en un editor de código y agrega el siguiente código:

let recognizer;

function predictWord() {
 // Array of words that the recognizer is trained to recognize.
 const words = recognizer.wordLabels();
 recognizer.listen(({scores}) => {
   // Turn scores into a list of (score,word) pairs.
   scores = Array.from(scores).map((s, i) => ({score: s, word: words[i]}));
   // Find the most probable word.
   scores.sort((s1, s2) => s2.score - s1.score);
   document.querySelector('#console').textContent = scores[0].word;
 }, {probabilityThreshold: 0.75});
}

async function app() {
 recognizer = speechCommands.create('BROWSER_FFT');
 await recognizer.ensureModelLoaded();
 predictWord();
}

app();

5. Prueba la predicción

Asegúrate de que tu dispositivo tenga un micrófono. Vale la pena destacar que esto también funcionará en un teléfono celular. Para ejecutar la página web, abre index.html en un navegador. Si trabajas desde un archivo local, para acceder al micrófono, deberás iniciar un servidor web y usar http://localhost:port/.

Para iniciar un servidor web simple en el puerto 8000, haz lo siguiente:

python -m SimpleHTTPServer

Es posible que tarde un poco en descargarse el modelo, así que ten paciencia. En cuanto se cargue el modelo, deberías ver una palabra en la parte superior de la página. El modelo se entrenó para reconocer los números del 0 al 9 y algunos comandos adicionales, como "left", "right", "yes", "no", etcétera.

Di una de esas palabras. ¿La reconoce correctamente? Juega con probabilityThreshold, que controla la frecuencia con la que se activa el modelo. 0.75 significa que el modelo se activará cuando tenga más del 75% de confianza de que escucha una palabra determinada.

Para obtener más información sobre el modelo de Speech Commands y su API, consulta el archivo README.md en GitHub.

6. Recopilar datos

Para que sea divertido, usemos sonidos cortos en lugar de palabras completas para controlar el deslizador.

Entrenarás un modelo para reconocer 3 comandos diferentes: "Left", "Right" y "Noise", que harán que el deslizador se mueva hacia la izquierda o la derecha. Reconocer "Noise" (no se necesita ninguna acción) es fundamental en la detección de voz, ya que queremos que el deslizador reaccione solo cuando producimos el sonido correcto y no cuando hablamos y nos movemos en general.

  1. Primero, debemos recopilar datos. Para agregar una IU simple a la app, agrega lo siguiente dentro de la etiqueta <body> antes de <div id="console">:
<button id="left" onmousedown="collect(0)" onmouseup="collect(null)">Left</button>
<button id="right" onmousedown="collect(1)" onmouseup="collect(null)">Right</button>
<button id="noise" onmousedown="collect(2)" onmouseup="collect(null)">Noise</button>
  1. Agrega lo siguiente a index.js:
// One frame is ~23ms of audio.
const NUM_FRAMES = 3;
let examples = [];

function collect(label) {
 if (recognizer.isListening()) {
   return recognizer.stopListening();
 }
 if (label == null) {
   return;
 }
 recognizer.listen(async ({spectrogram: {frameSize, data}}) => {
   let vals = normalize(data.subarray(-frameSize * NUM_FRAMES));
   examples.push({vals, label});
   document.querySelector('#console').textContent =
       `${examples.length} examples collected`;
 }, {
   overlapFactor: 0.999,
   includeSpectrogram: true,
   invokeCallbackOnNoiseAndUnknown: true
 });
}

function normalize(x) {
 const mean = -100;
 const std = 10;
 return x.map(x => (x - mean) / std);
}
  1. Quita predictWord() de app():
async function app() {
 recognizer = speechCommands.create('BROWSER_FFT');
 await recognizer.ensureModelLoaded();
 // predictWord() no longer called.
}

Desglose

Este código puede ser abrumador al principio, así que vamos a desglosarlo.

Agregamos tres botones a nuestra IU con las etiquetas "Left", "Right" y "Noise", que corresponden a los tres comandos que queremos que reconozca nuestro modelo. Cuando se presionan estos botones, se llama a nuestra función collect() recién agregada, que crea ejemplos de entrenamiento para nuestro modelo.

collect() asocia una label con el resultado de recognizer.listen(). Como includeSpectrogram es verdadero, recognizer.listen() proporciona el espectrograma sin procesar (datos de frecuencia) durante 1 segundo de audio, dividido en 43 fotogramas, por lo que cada fotograma es de ~23 ms de audio:

recognizer.listen(async ({spectrogram: {frameSize, data}}) => {
...
}, {includeSpectrogram: true});

Como queremos usar sonidos cortos en lugar de palabras para controlar el deslizador, solo tenemos en cuenta los últimos 3 fotogramas (~70 ms):

let vals = normalize(data.subarray(-frameSize * NUM_FRAMES));

Para evitar problemas numéricos, normalizamos los datos para que tengan un promedio de 0 y una desviación estándar de 1. En este caso, los valores del espectrograma suelen ser números negativos grandes alrededor de -100 y una desviación de 10:

const mean = -100;
const std = 10;
return x.map(x => (x - mean) / std);

Por último, cada ejemplo de entrenamiento tendrá 2 campos:

  • label****: 0, 1 y 2 para "Left", "Right" y "Noise", respectivamente
  • vals****: 696 números que contienen la información de frecuencia (espectrograma)

y almacenamos todos los datos en la variable examples:

examples.push({vals, label});

7. Prueba la recopilación de datos

Abre index.html en un navegador y deberías ver 3 botones correspondientes a los 3 comandos. Si trabajas desde un archivo local, para acceder al micrófono, deberás iniciar un servidor web y usar http://localhost:port/.

Para iniciar un servidor web simple en el puerto 8000, haz lo siguiente:

python -m SimpleHTTPServer

Para recopilar ejemplos de cada comando, haz un sonido coherente de forma repetida (o continua) mientras mantienes presionado cada botón durante 3 o 4 segundos. Debes recopilar ~150 ejemplos para cada etiqueta. Por ejemplo, podemos chasquear los dedos para "Left", silbar para "Right" y alternar entre silencio y hablar para "Noise".

A medida que recopiles más ejemplos, el contador que se muestra en la página debería aumentar. También puedes inspeccionar los datos llamando a console.log() en la variable examples de la consola. En este punto, el objetivo es probar el proceso de recopilación de datos. Más adelante, volverás a recopilar datos cuando pruebes toda la app.

8. Entrenar un modelo

  1. Agrega un botón "Train" justo después del botón "Noise" en el cuerpo de index.html:
<br/><br/>
<button id="train" onclick="train()">Train</button>
  1. Agrega lo siguiente al código existente en index.js:
const INPUT_SHAPE = [NUM_FRAMES, 232, 1];
let model;

async function train() {
 toggleButtons(false);
 const ys = tf.oneHot(examples.map(e => e.label), 3);
 const xsShape = [examples.length, ...INPUT_SHAPE];
 const xs = tf.tensor(flatten(examples.map(e => e.vals)), xsShape);

 await model.fit(xs, ys, {
   batchSize: 16,
   epochs: 10,
   callbacks: {
     onEpochEnd: (epoch, logs) => {
       document.querySelector('#console').textContent =
           `Accuracy: ${(logs.acc * 100).toFixed(1)}% Epoch: ${epoch + 1}`;
     }
   }
 });
 tf.dispose([xs, ys]);
 toggleButtons(true);
}

function buildModel() {
 model = tf.sequential();
 model.add(tf.layers.depthwiseConv2d({
   depthMultiplier: 8,
   kernelSize: [NUM_FRAMES, 3],
   activation: 'relu',
   inputShape: INPUT_SHAPE
 }));
 model.add(tf.layers.maxPooling2d({poolSize: [1, 2], strides: [2, 2]}));
 model.add(tf.layers.flatten());
 model.add(tf.layers.dense({units: 3, activation: 'softmax'}));
 const optimizer = tf.train.adam(0.01);
 model.compile({
   optimizer,
   loss: 'categoricalCrossentropy',
   metrics: ['accuracy']
 });
}

function toggleButtons(enable) {
 document.querySelectorAll('button').forEach(b => b.disabled = !enable);
}

function flatten(tensors) {
 const size = tensors[0].length;
 const result = new Float32Array(tensors.length * size);
 tensors.forEach((arr, i) => result.set(arr, i * size));
 return result;
}
  1. Llama a buildModel() cuando se cargue la app:
async function app() {
 recognizer = speechCommands.create('BROWSER_FFT');
 await recognizer.ensureModelLoaded();
 // Add this line.
 buildModel();
}

En este punto, si actualizas la app, verás un nuevo botón "Train". Puedes probar el entrenamiento volviendo a recopilar datos y haciendo clic en "Train", o puedes esperar hasta el paso 10 para probar el entrenamiento junto con la predicción.

Desglose

En un nivel superior, hacemos dos cosas: buildModel() define la arquitectura del modelo y train() entrena el modelo con los datos recopilados.

Arquitectura del modelo

El modelo tiene 4 capas: una capa convolucional que procesa los datos de audio (representados como un espectrograma), una capa de agrupación máxima, una capa de aplanamiento y una capa densa que se asigna a las 3 acciones:

model = tf.sequential();
 model.add(tf.layers.depthwiseConv2d({
   depthMultiplier: 8,
   kernelSize: [NUM_FRAMES, 3],
   activation: 'relu',
   inputShape: INPUT_SHAPE
 }));
 model.add(tf.layers.maxPooling2d({poolSize: [1, 2], strides: [2, 2]}));
 model.add(tf.layers.flatten());
 model.add(tf.layers.dense({units: 3, activation: 'softmax'}));

La forma de entrada del modelo es [NUM_FRAMES, 232, 1], en la que cada fotograma es de 23 ms de audio que contiene 232 números que corresponden a diferentes frecuencias (se eligió 232 porque es la cantidad de buckets de frecuencia necesarios para capturar la voz humana). En este codelab, usamos muestras de 3 fotogramas de largo (~70 ms) ya que hacemos sonidos en lugar de decir palabras completas para controlar el deslizador.

Compilamos nuestro modelo para prepararlo para el entrenamiento:

const optimizer = tf.train.adam(0.01);
 model.compile({
   optimizer,
   loss: 'categoricalCrossentropy',
   metrics: ['accuracy']
 });

Usamos el optimizador Adam, un optimizador común que se usa en el aprendizaje profundo, y categoricalCrossEntropy para la pérdida, la función de pérdida estándar que se usa para la clasificación. En resumen, mide qué tan lejos están las probabilidades predichas (una probabilidad por clase) de tener un 100% de probabilidad en la clase verdadera y un 0% de probabilidad para todas las demás clases. También proporcionamos accuracy como una métrica para supervisar, que nos dará el porcentaje de ejemplos que el modelo obtiene correctamente después de cada época de entrenamiento.

Capacitación

El entrenamiento se realiza 10 veces (épocas) sobre los datos con un tamaño de lote de 16 (procesando 16 ejemplos a la vez) y muestra la precisión actual en la IU:

await model.fit(xs, ys, {
   batchSize: 16,
   epochs: 10,
   callbacks: {
     onEpochEnd: (epoch, logs) => {
       document.querySelector('#console').textContent =
           `Accuracy: ${(logs.acc * 100).toFixed(1)}% Epoch: ${epoch + 1}`;
     }
   }
 });

9. Actualiza el deslizador en tiempo real

Ahora que podemos entrenar nuestro modelo, agreguemos código para hacer predicciones en tiempo real y mover el deslizador. Agrega lo siguiente justo después del botón "Train" en index.html:

<br/><br/>
<button id="listen" onclick="listen()">Listen</button>
<input type="range" id="output" min="0" max="10" step="0.1">

Y lo siguiente en index.js:

async function moveSlider(labelTensor) {
 const label = (await labelTensor.data())[0];
 document.getElementById('console').textContent = label;
 if (label == 2) {
   return;
 }
 let delta = 0.1;
 const prevValue = +document.getElementById('output').value;
 document.getElementById('output').value =
     prevValue + (label === 0 ? -delta : delta);
}

function listen() {
 if (recognizer.isListening()) {
   recognizer.stopListening();
   toggleButtons(true);
   document.getElementById('listen').textContent = 'Listen';
   return;
 }
 toggleButtons(false);
 document.getElementById('listen').textContent = 'Stop';
 document.getElementById('listen').disabled = false;

 recognizer.listen(async ({spectrogram: {frameSize, data}}) => {
   const vals = normalize(data.subarray(-frameSize * NUM_FRAMES));
   const input = tf.tensor(vals, [1, ...INPUT_SHAPE]);
   const probs = model.predict(input);
   const predLabel = probs.argMax(1);
   await moveSlider(predLabel);
   tf.dispose([input, probs, predLabel]);
 }, {
   overlapFactor: 0.999,
   includeSpectrogram: true,
   invokeCallbackOnNoiseAndUnknown: true
 });
}

Desglose

Predicción en tiempo real

listen() escucha el micrófono y hace predicciones en tiempo real. El código es muy similar al método collect(), que normaliza el espectrograma sin procesar y descarta todos los fotogramas, excepto los últimos NUM_FRAMES. La única diferencia es que también llamamos al modelo entrenado para obtener una predicción:

const probs = model.predict(input);
const predLabel = probs.argMax(1);
await moveSlider(predLabel);

El resultado de model.predict(input) es un tensor de forma [1, numClasses] que representa una distribución de probabilidad sobre la cantidad de clases. En términos más sencillos, este es solo un conjunto de confianzas para cada una de las posibles clases de salida que suman 1. El tensor tiene una dimensión externa de 1 porque ese es el tamaño del lote (un solo ejemplo).

Para convertir la distribución de probabilidad en un solo número entero que represente la clase más probable, llamamos a probs.argMax(1), que muestra el índice de clase con la probabilidad más alta. Pasamos un "1" como parámetro de eje porque queremos calcular el argMax sobre la última dimensión, numClasses.

Actualiza el deslizador

moveSlider() disminuye el valor del deslizador si la etiqueta es 0 ("Left") , lo aumenta si la etiqueta es 1 ("Right") y lo ignora si la etiqueta es 2 ("Noise").

Descarta tensores

Para limpiar la memoria de la GPU, es importante que llamemos manualmente a tf.dispose() en los tensores de salida. La alternativa a tf.dispose() manual es ajustar las llamadas a funciones en un tf.tidy(), pero esto no se puede usar con funciones asíncronas.

   tf.dispose([input, probs, predLabel]);

10. Prueba la app final

Abre index.html en tu navegador y recopila datos como lo hiciste en la sección anterior con los 3 botones correspondientes a los 3 comandos. Recuerda mantener presionado cada botón durante 3 o 4 segundos mientras recopilas datos.

Una vez que hayas recopilado ejemplos, presiona el botón "Train". Esto comenzará a entrenar el modelo y deberías ver que la precisión del modelo supera el 90%. Si no logras un buen rendimiento del modelo, intenta recopilar más datos.

Una vez que finalice el entrenamiento, presiona el botón "Listen" para hacer predicciones desde el micrófono y controlar el deslizador.

Consulta más instructivos en http://js.tensorflow.org/.