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 control deslizante en el navegador con 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, crearás y entrenarás una red neuronal simple que reconozca tus sonidos y haga que el control deslizante vaya hacia la izquierda o la derecha.

En este codelab, no se explicará la teoría detrás de los modelos de reconocimiento de audio. Si te interesa obtener información al respecto, consulta este instructivo.

También creamos un glosario con términos de aprendizaje automático que puedes encontrar 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 actualizado
  2. Un editor de texto que se ejecute de manera local en tu máquina o en la Web con métodos como CodePen o Glitch
  3. Conocimiento de HTML, CSS, JavaScript y las Herramientas para desarrolladores de Chrome (o las de tu navegador preferido)
  4. Una comprensión conceptual de alto nivel de las redes neuronales. Si necesitas una introducción o un repaso, mira 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 comandos de voz previamente entrenado. La etiqueta <div id="console"> se usará para mostrar la salida del modelo.

4. Realizar predicciones en tiempo real

A continuación, abre o crea el archivo index.js en un editor de código y, luego, incluye 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 señalar 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, deberás iniciar un servidor web y usar http://localhost:port/ para acceder al micrófono.

Para iniciar un servidor web simple en el puerto 8000:

python -m SimpleHTTPServer

Es posible que la descarga del modelo demore un poco; ten paciencia. Apenas 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 “izquierda”, “derecha”, “sí”, “no”, etcétera.

Di una de esas palabras. ¿Comprende lo que dices correctamente? Juega con el 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 certeza de que escucha una palabra determinada.

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

6. Recopilar datos

Para que sea más divertido, usemos sonidos breves en lugar de palabras completas para controlar el control deslizante.

Entrenarás un modelo para que reconozca 3 comandos diferentes: “Izquierda” o “Derecha”. y "Ruido" lo que hará que el control deslizante se mueva hacia la izquierda o la derecha. Reconociendo el "ruido" (no es necesario realizar ninguna acción) es fundamental en la detección de voz, ya que queremos que el control deslizante solo reaccione cuando se produce el sonido correcto, y no cuando estamos hablando en general y en movimiento.

  1. Primero, necesitamos recopilar los datos. Para agregar una IU simple a la app, incluye 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 analizarlo.

Agregamos tres botones a nuestra IU etiquetados como “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 un label con el resultado de recognizer.listen(). Dado que includeSpectrogram es verdadero,, recognizer.listen() proporciona el espectrograma sin procesar (datos de frecuencia) de 1 s de audio, dividido en 43 fotogramas, por lo que cada fotograma mide ~23 ms de audio:

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

Como queremos usar sonidos cortos en lugar de palabras para controlar el control deslizante, solo estamos considerando 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 "Izquierda", "Derecha" y "Ruido" 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. Cómo probar la recopilación de datos

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

Para iniciar un servidor web simple en el puerto 8000:

python -m SimpleHTTPServer

Si quieres 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 unos 150 ejemplos para cada etiqueta. Por ejemplo, podemos hacer un chasquido de dedos para "Izquierda", silbar para "Derecha" y alternar entre silencio y hablar para "Ruido".

A medida que recopiles más ejemplos, el contador que se muestra en la página debería aumentar. Si lo deseas, 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 mientras pruebes toda la app.

8. Entrenar un modelo

  1. Agrega un “Train” (Entrenar). justo después de "Ruido" botón del cuerpo en 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 el nuevo "Entrenar" . Para probar el entrenamiento, vuelve a recopilar datos y haz clic en “Entrenar” o puedes esperar hasta el paso 10 para probar el entrenamiento junto con la predicción.

Análisis

En un nivel alto, 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 convolucional que procesa los datos de audio (representado como un espectrograma), una capa de piscina máxima, una capa compactada 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 intervalos de frecuencia necesarios para capturar la voz humana). En este codelab, usamos muestras de 3 fotogramas de longitud (muestras de aproximadamente 70 ms), ya que hacemos sonidos en lugar de decir palabras completas para controlar el control deslizante.

Compilamos nuestro modelo a fin de tenerlo listo 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 pérdida, la función de pérdida estándar que se usa en 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 una probabilidad del 0% para todas las demás clases. También proporcionamos accuracy como métrica para supervisar, que nos dará el porcentaje de ejemplos que el modelo acerta después de cada ciclo de entrenamiento.

Capacitación

El entrenamiento se extiende 10 veces (ciclos de entrenamiento) sobre los datos con un tamaño de lote de 16 (procesando 16 ejemplos a la vez) y muestra la exactitud 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 control deslizante en tiempo real

Ahora que podemos entrenar nuestro modelo, agreguemos código para hacer predicciones en tiempo real y mover el control deslizante. Agrégalo justo después de "Train" (Entrenar). 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
 });
}

Análisis

Predicción en tiempo real

listen() escucha al micrófono y realiza 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 NUM_FRAMES, excepto los últimos. 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 en la cantidad de clases. En pocas palabras, esto es solo un conjunto de confianzas para cada una de las clases de salida posibles, 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 del eje porque queremos calcular el argMax en la última dimensión, numClasses.

Actualiza el control deslizante

moveSlider() disminuye el valor del control deslizante si la etiqueta es 0 ("Izquierda") y lo aumenta si la etiqueta es 1 ("Derecha") e ignora si la etiqueta es 2 ("Ruido").

Cómo descartar tensores

Para limpiar la memoria de la GPU, es importante llamar manualmente a tf.dispose() en los tensores de salida. La alternativa al tf.dispose() manual es unir 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 el archivo 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 los ejemplos, presiona el botón “Entrenar” (Train). Esto iniciará el entrenamiento del modelo, y deberías ver que la precisión del modelo supera el 90%. Si no obtienes un buen rendimiento del modelo, intenta recopilar más datos.

Una vez finalizado el entrenamiento, presiona el botón "Escuchar" para hacer predicciones desde el micrófono y controlar el control deslizante.

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