Reconocimiento de voz con ATmega128 (cont.)

Filtros por “correlation” recursivos

Observemos el código de estos filtros:

  decrement_p (&pp);                                               // pp = (pp - 1) & 31;
  for (f=0; f<4; f++) {
    filter[f] -= superaudio[pp]*coseno[(1<<f)*(pp&(15>>f))]/32;    // filtros por 'correlation' recursivos a 500, 1K, 2K y 4KHz
  }
  superaudio [pp] = analogRead(0);                                 // toma una nueva muestra
  for (f=0; f<4; f++) {
    filter[f] += superaudio[pp]*coseno[(1<<f)*(pp&(15>>f))]/32;    // 2a. fase de los filtros
    decimator[f] += sq(filter[f]);                                 // RMS
  }

superaudio[] es un buffer circular que contiene las últimas 32 muestras del audio;

pp es el puntero de dicho buffer.

Simplemente lo que yo hice acá, es tomar la fórmula recursiva de un filtro Moving Average de 32 puntos, pero con una pequeña variante: en lugar de sumar directamente todas las muestras, las sumo previamente multiplicadas por una sinusoide. Hago eso 4 veces, utilizando 4 sinusoides distintas (que son en realidad distintos incrementos en una misma tabla de 32 valores, calculados previamente). Finalmente cada punto así calculado de los filtros, es elevado al cuadrado y sumado en un acumulador que se resetea cada 32 muestras.

Como ven, se trata de una gran “chanchada”, pero tiene 2 características importantes:

  1. es verdaderamente sensible a las frecuencias (aunque es probable que la respuesta de cada filtro dé unas curvas completamente amorfas);
  2. es verdaderamente liviano computacionalmente; sinceramente no sé si existe algo más liviano, me gustaría saberlo.

Observemos ahora qué es lo que ocurre al cumplirse las 32 muestras:

  if (pp==0) {                                              // cada 32 muestras
    char lastcode = code;
    for (f=0; f<4; f++) {
      envelope[f] = .55*envelope[f] + decimator[f];         // pasabajos recursivo, genera una "envolvente"
      decimator[f] = 0;                                     // resetea los acumuladores de potencia
    }    
    code = compare (envelope);          // esto devuelve un código (A, B, C, D, etc.) 
    if (code != lastcode ) {           
      palabra += lastcode;
      palabra += String (runlength);
      palabra += ',';
      if (lastcode == 'T') {done = true;}    // "done" indica que terminó una "palabra"
      runlength = 1;
    } else {
      if (runlength != 0) {runlength++;}
      if (runlength > 79) {runlength = 0; code = 'T';}         // timeout de silencio
    } 
    if (palabra.length()>99) {done = true;}
  }

Primero que nada, los valores de los acumuladores de potencia son “suavizados” con un nuevo filtro recursivo de 1 polo.

Lo que sigue es la rutina encargada de generar la cadena RLE. Envía las 4 frecuencias a la rutina “compare”, la cual le devuelve un código. El programa simplemente lleva la cuenta del último código recibido, la cantidad de ocurrencias (runlength), y se encarga de interrumpir el proceso cuando se detectan demasiadas ocurrencias seguidas de un mismo código, o cuando la cadena total se hace demasiado larga. La forma de hacerlo es activando el flag “done”; no olvidemos que todo este código se está ejecutando en la ISR, de modo que la única forma de detenerlo es desde el programa principal.

La rutina “compare” es el verdadero corazón de todo esto, y es, pese a su simpleza, la que me insumió prácticamente los 6 meses de trabajo, dado que el resto del código lo tuve pronto desde los primeros dias. Echémosle un vistazo:

char compare (float envelope[]) {    // de este algoritmo de comparación depende la "signature" del audio
  char result = '@';
  float weighted[] = {envelope[0], 2*envelope[1], 4*envelope[2], 8*envelope[3]};
  // busca el componente mayor
  byte maxx = 0;
  for (byte i=1; i<4; i++) {                                                                  
    if (weighted [i] > weighted [maxx]) {maxx = i;}
  }
  // pureba del umbral
  if (weighted[maxx] > 16) {
    // prueba de proximidad
    float energia = 0;
    for (byte i=0; i<4; i++) {
      if (weighted[maxx]-weighted[i] < .3) {result += (1<<i);}        // tolerancia de proximidad
      energia += weighted[i];
    }
    if (energia > max_val) {max_pos = count; max_val = energia;}
  } 
  count ++;
  return result;
}

Lo primero que hace esta rutina es multiplicar cada filtro por unos coeficientes iniciales. Dado que la “caracterización” funciona en base a la frecuencia con mayor potencia dentro del bloque, estos coeficientes hacen las veces de “ecualizador”, generando un panorama mucho más interesante para la comparación que sigue. La “prueba del umbral” establece que si el mayor de los componentes no supera cierto umbral, el código retornado será ‘@’; de lo contrario, el código retornado será una letra entre la ‘A’ y la ‘O’, es decir ‘@’  + un número de 4 bits, en el que cada bit representa si la frecuencia “está” o no, pero normalmente sólo “está” la frecuencia con mayor potencia y alguna que se le aproxime mucho (“tolerancia de proximidad”).

Nótese que esta rutina, además de devolver el código de caracterización del bloque, incrementa un misterioso “count”, y setea unos no menos misteriosos “max_pos” y “max_val”, en base a una enigmática “energía”. Estos valores van directamente a la rutina encargada de generar la “signature”, y ya veremos cómo funcionan.

  

Procesamiento off-line.

Una vez capturada la “palabra” entera, llega el momento de extraer la firma a partir del código RLE, y pasarla a la red neuronal. Veamos el código encargado de esto:

  byte largo = 0, cambios = 0;
  int filter [4] = {0, 0, 0, 0};
  int X = -1;
  boolean sintaxis = true;
  if (palabra.startsWith("@0,") && palabra.endsWith("T0,") && palabra.length()>6) {
    palabra = palabra.substring(3, palabra.length()-3);
    do {
      byte coma = palabra.indexOf(',');
      byte num = atoi ((palabra.substring(1, coma)).toCharArray());           // string to integer
      char code = palabra.charAt(0);
      if (code == '@') {
        if (X != -1) {X += num; largo += num; cambios++;} else {max_pos -= num;}
      } else {
        largo += num; cambios++;
        for (int f=0; f<4; f++) {
          if (bitRead(code-'@', f)) {filter [f] += num;}            
        }
        if (X == -1) {X = 0;}
      }
      palabra = palabra.substring(coma+1, palabra.length());
    } while (palabra != "");
  } else {sintaxis = false;}
  

Lo que hace esta rutina es registrar el número de bloques total de la palabra (largo), la cantidad de “unidades fonéticas” (por llamarlas de alguna manera) distintas que contiene (cambios), la cantidad de bloques de silencio (‘@’) y la cantidad de bloques en los que “está” cada una de las 4 frecuencias. En total tenemos hasta aquí 7 datos. Como acertadamente observó Alvaro Cassinelli en su momento, no hay en esto ninguna referencia al orden temporal en que sucedieron las “unidades fonéticas”.

Precisamente, este es el más grave defecto de que adolece, porque este sistema, en el mejor de los casos, daría la misma firma para las palabras “monja” y “jamón”, por ejemplo, o para “zorra” y “arroz”.

Estuve mucho tiempo intentando idear un algoritmo de firma que contemplara la información temporal, pero que mantuviera la característica de poder expresarse en un pequeño número fijo de parámetros para enviar a la red neuronal. No lo conseguí. Noté que la mayoría de los sistemas de reconocimiento auditivo que existen comercialmente, utilizan los Modelos Ocultos de Markov (HMM) a la hora de lidiar con esta parte del problema, pero era tarde para ponerme a aprender toda una teoría completamente nueva para mí (lo de las Redes Artificiales de Neuronas, en cambio, lo venía “mangiando” desde hacía más de 10 años).

Finalmente tuve una idea: ¿qué tal si, en forma paralela a todo este procesamiento, no realizábamos otro basado en la amplitud de la señal, que sí contemplara la información temporal? Esta idea cristalizó en la misteriosa línea que veíamos en la rutina “compare”, que básicamente lo que hace es buscar el bloque con más “energía” en toda la palabra, y dar cuenta de su ubicación temporal. La “energía” se calcula como la suma de los cuatro filtros ya ponderados. Nuevamente, se trata de otro dato arbitrario, pero que aporta una mínima referencia temporal.

De esta manera queda conformado el octeto de parámetros que se introduce en la red neuronal, o sea la “signature”. La rutina que lo hace es trivial, pero aquí está:

  if (sintaxis) {
    float input[] = {largo, cambios, X, filter[0], filter[1], filter[2], filter[3], max_pos};
    float output[SALIDAS];
    nn_update (input, output);
    byte maxx = 0;
    for (int i=1; i<SALIDAS; i++) {
      if (output[i]>output[maxx]) {maxx = i;}
    }
    loadChr ('0'-32+maxx);
    writeScreen();
  }

input[] contiene los 8 parámetros y nn_update actualiza la red neuronal dejando los resultados en output[]. Acto seguido, se busca la salida con mayor nivel, y ése será el resultado. Nótese que en realidad, tenemos más información que este simple resultado, si analizamos la propia firma bajo ciertas directivas, y también la información que sale de la red neuronal, ya que no se trata de un entero entre 0 y 9, sino de 10 “floats” entre 0 y 1. Por ejemplo, estas serían algunas pautas para “saber algo más” sobre lo que el robot “escuchó”:

  • muchos cambios = palabra articulada / pocos cambios = sonido contínuo
  • predominancia de @ y pocos cambios = bajo volumen, mayor distancia del emisor.
  • varias salidas activadas de la red neuronal = (investigar).
  • ninguna salida por encima de 0.5 = (investigar).
  • etc.

Pablo Gindel, 5-3-10

Compártelo:
  • BarraPunto
  • Bitacoras.com
  • email
  • Facebook
  • Google Bookmarks
  • LinkedIn
  • Live
  • MySpace
  • PDF
  • Print
  • RSS
  • Twitter
  • Yahoo! Bookmarks
  • Yahoo! Buzz
  • Add to favorites
  • del.icio.us
  • Digg