Separar palabras en sílabas

Este algoritmo esta inspirado en el paper «A Syllabification Algorithm for Spanish» de Heriberto Cuayáhuitl. Pero realizando algunos cambios.

Para separar las palabras en sílabas usamos un algoritmo de dos pasos.

Paso 1

La clave del primer paso está en analizar las consonantes entre vocales. Podemos tener los siguientes casos: VCV, VCCV, VCCCV, VCCCCV. Siempre dividimos por delante de la última consonante: V|CV, VC|CV, VCC|CV, VCCC|CV . Sin embargo si la última consonante es una ‘r’, ‘l’ o ‘h’. Estamos ante una estructura de dos consonantes como cr, fr, rr, ll, ch,… Por lo que desplazaremos el corte a la consonante anterior. Hay que tener en cuenta algún detalle más. Cuando hay varias vocales juntas actúan como si fueran una sola. Así que realmente las V pueden representar a varias vocales.

El algoritmo necesita una variable temporal donde se almacena lo queda a la derecha del punto de corte. Pero antes de actualizarla esa variable hay que tomar el valor almacenado en ella, añadirle lo que queda a la izquierda del punto de corte y almacenarlo como una de las silabas. Puede que algunas de la silabas no sean correctas, de ellas se ocupará el paso 2.

El algoritmo elimina la parte analizada de la palabra y es como si siempre estuviera al principio de la misma. Para ello el algoritmo al cortar un bloque de vocales y consonantes toma el lado de la izquierda, lo junta con lo que haya almacenado en la variable temporal (el lado de la derecha del anterior corte), eso forma una silaba y la guarda. Luego almacena en la variable la parte de la derecha del corte hasta poder unirlo con la parte izquierda del siguiente corte (si no hubiera más cortes lo guarda y finaliza).

Al final las reglas que quedan son:

La palabra comienza por….GuardarVariable Temporal (T)
V -> | VV
CV -> | CVTCV
C[rlh]V -> | C[rlh]VTC[rlh]V
CCV -> C | CVT+CCV
CC[rlh]V -> C | C[rlh]VT+CC[rlh]V
CCCV -> CC | CVT+CCCV
CCC[rlh]V -> CC | C[rlh]VT+CCC[rlh]V
CCCCV -> CCC | CVT+CCCCV
C* -> C* |T+C*

Siendo: V = vocal/es, C = Consonante, [rlh] = consonante que sea r, l, h, T = variable temporal, | = punto de corte

La última regla hace referencia a que solo quedan caracteres y ninguna vocal, en ese caso se unen todo a lo que haya en la memoria temporal.

Ejemplos:

PalabraReglaBloqueGuardarTemporalSílabas
instrumentoV -> |V| i i
nstrumentoCCC[rlh]V -> CC | C[rlh]Vns | trui+nstruins,
mentoCV -> | CV| metrumeins, tru
ntoCCV -> C | CVn | tome+ntoins, tru, men
  to ins, tru, men, to
PalabraReglaBloqueGuardarTemporalSílabas
trompetaC[rlh]V -> | C[rlh]V| tro tro
mpetaCCV -> C | CVm | petro+mpetrom,
taCV -> | CV| tapetatrom, pe
tatrom, pe, ta
PalabraReglaBloqueGuardarTemporalSílabas
aviónV -> |V| a a
viónCV -> | CV| vióavióa,
nC* -> C* |n | vió + na, vión

 Paso 2

En el segundo paso repasamos cada uno de los grupos creados en el paso anterior y vamos a dividir los grupos de vocales cuando sea necesario. Separaremos las vocales en dos grupos: débiles {i,u} y fuerte {a,e,o}. Denotaremos el grupo de débiles con d y el de fuertes con f. A su vez pueden estar acentuadas o no, lo indicaremos como D {í,ú} y F {á,é,ó}.

Para dos vocales juntas las reglas son:

  • Permanecen juntas: dd, df, dF, Fd
  • Se separan:  ff, Ff, fF, Df, fD

En el caso de tres vocales solo se separan si hay dos fuertes juntas.

  • {iuüíú}{aeoáéó}{aeoáéó} -> {iuüíú}{aeoáéó} | {aeoáéó}
  • {aeoáéó}{aeoáéó}{iuüíú} -> {aeoáéó} | {aeoáéó}{iuüíú}

Ejemplo:

«avión» tras el paso uno queda dividida en «a»,»vión». Al ser «vión» un caso dF las vocales no se dividen, el paso dos devolverá «a»,»vión»

«rocíen» se separará en el paso uno en «ro»,»cíen». Como íe es un caso de Df se separara «ro»,»cí»,»en»

En cambio «ca»,»ca»,»hue»,»te» en el caso de «hue» al ser df seguirán juntas.


Todo esto está recopilado en la librería jsESsyllable.

var syllable = new jsESsyllable();

var word = "...";
var syllables = syllable.divide(word);

Puedes usar la demo online.

Tienes un vídeo explicando este mismo algoritmo en mi canal de youtube:

Haz click para ver el vídeo en mi canal de youtube

Capturar vídeo de la cámara del dispositivo en HTML5

Para dotar a nuestro agente de ojos debemos poder acceder a las cámaras del dispositivo. Para ellos nuestra web va a necesitar un elemento vídeo y otro canvas. Enlazamos el vídeo con la webcam del dispositivo y cada cierto tiempo tomamos una captura que copiaremos sobre el canvas para poder acceder a los pixels y aplicar nuestros algoritmos de visión por computador.
Empecemos por incluir una etiqueta vídeo y una canvas en nuestra web:



Las funciones que nos da HTML5 para acceder al vídeo se basan en la API navigator.mediaDevices. En versiones antiguas de los navegadores pierdes encontrarte el problema de que los nombres no están unificados y cada uno lo llamaba de una manera. Actualmente ya lo están. Algo parecido nos pasa con la API window.URL que vamos a usar para poder conectar la webcam a nuestro elemento vídeo. Para que no haya problemas entre navegadores usaremos:

window.URL = window.URL
|| window.webkitURL
|| window.mozURL
|| window.msURL;

Nos hace falta acceder a cada uno de los elementos. El vídeo, el canvas y el contexto del canvas del cual leeremos la imagen capturada del vídeo. Para ello usaremos el id del tag.

video = document.getElementById(videoId);
canvas = document.getElementById(canvasId);
context = canvas.getContext('2d');

Para enlazar la webcam con el elemento vídeo antes hay que saber que webcam es ya que un dispositivo puede tener varias, por ejemplo un móvil tiene frontal y trasera. Para saber que dispositivos hay puedes usar la API navigator.mediaDevices.enumerateDevices . Una vez seleccionado el dispositivo que quieras usar se emplea el metodo getUserMedia() pasandole una estructura del tipo MediaStreamConstraints donde describes los requisitos que necesitas que tenga el dispositivo.  En nuestro caso va ser mas simple vamos a contemplar solo la cámara frontal, la trasera y la por defecto.

//Cámara frontal
device.video = {facingMode: "user", deviceId: ""};
//Cámara trasera
device.video = {facingMode: "environment", deviceId: ""};
//Cámara por defecto (frontal en los móviles)
device.video = {deviceId: "default"};

También podemos desear ajustar la resolución:

device.video.width = 320;
device.video.height = 240;

y el framerate.

this.configuration.framerate = 25;

Hay que decir que realmente estas condiciones no obligan que devolver un dispositivo que las cumpla, solo aconsejan que lo sea, así que no puedes confiar que la resolución sea la deseada y debes de verificarlos.

navigator.mediaDevices.getUserMedia(device)
.then(
  function(stream) { ...}
).catch(...)

Una vez conectada a la webcam hemos de conectar esta con el elemento vídeo de la web para ello usamos window.URL.createObjectURL :

navigator.mediaDevices.getUserMedia(device)
.then(
  function(stream) {
    var src = window.URL.createObjectURL(stream);
    video.src = src;
  }
).catch(
  function(e) { console.log("Video error: "+e); }
);

Y por ultimo cada cierto tiempo hemos de capturar el frame que se esta mostrando en el video y volcarlo al canvas, desde donde podremos acceder a todos sus pixeles. El proceso es muy sencillo, tan sencillo como copiarla al canvas usando el método drawimage

canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.context.drawImage(video, 0, 0, this.video.videoWidth, this.video.videoHeight);

Lo mismo con videoToCanvas

Para hacer todo esto más sencillo se ha creado la librería videoToCanvas

//id del tag canvas y video
var v2c = new VideoToCanvas("canvas", "video");
//por defecto es 320x240
v2c.configuration.width=640;
v2c.configuration.height=480;
v2c.webcam();

Para realizar la captura al canvas es tan simple como:

v2c.snap();

Ademas cuenta con gran cantidad de funciones para controlar la reproducción del vídeo de la cámara o para cargar en su lugar un vídeo (útil para pruebas).

Acceder a los pixels de un canvas

Con esto tenemos ya el primer paso dado. Capturar la imagen. Antes de empezar a manipular los datos de un canvas tenemos que ver cómo trabajar con ellos.
Para recuperar los datos del canvas usamos la función getImageData del contexto

var imageData = canvas.context.getImageData(0, 0, width, height);
var data = imageData.data;

getImageData nos permite recuperar solo parte de la imagen indicando las coordenadas X e Y de la esquina superior izquierda del rectángulo de la imagen a recuperar y el ancho y el alto. En caso de que con las medidas especificadas recuperemos parte de de fuera de la imagen se devolverán pixels negros transparentes

Usando videoToCanvas tienes dos métodos getImageData(x, y, width, height) y getBoxes(rows, cols). el primero actúa igual que el getimageData del canvas.context y devuelve un ImageData. El segundo divide la imagen en rows*cols partes y devuelve un array de ImageData. Este método es útil cuando se va a trabajar en paralelo sobre distintas partes de la imagen.

En el ImageData devuelto encontramos las siguientes propiedades:

  • ImageData.height Alto de la imagen
  • ImageData.width Ancho de la imagen
  • ImageData.data Un array de bytes que contiene los pixels de la imagen en formato RGBA

Los píxels de la imagen se recuperan en formato RGBA. Que significa que cada píxel ocupa 4 bytes. Los 3 primeros se dedican a los componentes de color: rojo, verde y azul. El cuarto al nivel de transparencia, también llamado alpha. De esa forma data[0] corresponde al valor de la componente roja del primer píxel, data[1] a la verde, data[2] a la azul y data[3] a la alpha. Después repetimos esa misma distribución con data[4], data[5], data[6] y data[7]. Y así una y otra vez durante toda el array.

Los valores de cada pixel varían entre 0 y 255 e indican la intensidad de ese color en el pixel. Una ventaja del tipo de datos Uint8ClampedArray es que no hace falta comprobar los límites al asignarle un valor, todo valor menor que 0 se convierte a 0 y todo valor mayor de 255 se convierte a 255. Su mayor problema es que es un tipo bastante lento para operar con él así que vamos a tratar de reducir el número de operaciones sobre el mismo.

Flexiones y derivaciones de las palabras.

Uno de los mayores problemas que tenemos para conseguir que nuestro agente pueda tanto interpretar como generar con más naturalidad las frases ha de ser capaz de manejar las flexiones y derivaciones de nuestro lenguaje, al menos de una forma básica.

Eso le permite usar e interpretar correctamente el género, número, conjugaciones y tiempos verbales. Incluso aumentativos y diminutivos.

Reconozcamos que conversaciones como:

– Chispas Hola, soy Lorena

– Hola señor Lorena

– Chispas enciende las luces

– La luces está encendido

Quedan muy absurdas.

Desgraciadamente el lenguaje no se construye pensando en que sea fácil procesarlo por un algoritmo y al final el código acaba siendo un montón de reglas y excepciones. Hay que asumir que no son 100% correctos y que van a fallar en casos concretos que se salten las reglas. Hay mecanismos para incluir estás excepciones. Aún así hay que tratar de controlar el léxico usado. Como siempre es más fácil controlarlo si reducimos el lenguaje a un contexto concreto.

Pero es que hay que tener en cuenta que  todo puede funcionar bien y una palabra puede estar correctamente cambiada de género y número y aún así ser incorrecto su uso. Por ejemplo: «El manzano está en flor» si lo pasamos a femenino «La manzana está en flor» aunque manzana es correcto cambia el sentido de la frase. Ejemplos hay a montones «caso, casa», «paso, pasa»… Desgraciadamente es difícil de controlar ya que no solo depende de la palabra en sí, si no también del contexto y de su significado.

Además a veces el paso de una forma otra otro no es sencillo ya que se pierde información de la palabra original. Por ejemplo del plural al singular. En el caso de la palabras acabadas en es no queda claro cuáles tienes que quitar la terminación -es y cuales solo la -s.

Meses -> Mes

Ceses -> Cese

A todos estos problemas hay que contarle varios tipos de excepciones:

  • Verbos: llevan sus propias reglas (conjugaciones verbales) y varían según el género, número y tiempo.
  • Nombres propios: estos son un drama, los hay indistinguibles de nombres comunes (Rosa, Mar, Pilar). Los hay invariantes, que no varían con el número o el género: Sócrates y otros que si (María,Mario) y los que varían a veces no siguen las reglas habituales (Carlos, Carla).
  • Invariantes: palabras que no cambian su forma para los distintos géneros y/o números (Gafas).
  • Excepciones: palabras que no cumplen la reglas para cambiar el género o el número (Hombre/Mujer)

Los algoritmos has sido recogidos en el proyecto jsESinflection que está divido en cinco ficheros JS.

Plurales y singulares jsESnumber.js

Masculinos y femeninos jsESgender.js

Aumentativos jsESaugmentative.js

Diminutivos jsESdiminutive.js

Despreciativos jsESdespective.js

Para cargarlas se puede usar el siguite codigo:






var number = new jsESnumber();
var gender = new jsESgender();
var despective = new jsESdespective();
var augmentative = new jsESaugmentative();
var diminutive = new jsESdiminutive();

Las llamadas para transformar las palabras son sencillas:

number.pluralOf(word);
number.singularOf(word);
number.singularOrPlural(word);
gender.feminineOf(word);
gender.masculineOf(word);
gender.masculineOrFeminine(word);
despective.despectiveOf(word);
augmentative.augmentativeOf(word);
diminutive.diminutiveOf(word);

También permite añadir excepciones si las reglas que aplican las funciones no dan el resultado correcto:

number.addException(singular, plural);
gender.addException(masculine, feminine);
augmentative.addException(normal, augmentative);
diminutive.addException(normal, diminutive)
diminutive.addException(normal, despective);

La librería ya incluye las excepciones más comunes pero para declarar las propias es tan sencillo como pasarle el par de palabras. Por ejemplo:

//realmente es innecesario ya esta incluido en la librería
gender.addException("hombre", "mujer");

Me gustaría decir que funciona a la perfección en el 100% de los casos, pero el leguaje tiene demasiadas excepciones y peculiaridades apra que así sea, sin embargo da un buen resultado la mayoría de las veces.

Extraer lemas de un texto

Una vez visto como lematizar palabras vamos a ver una utilidad de ello. Sacar de que va un texto. En realidad sacar los lemas principales de un texto y así poder compararlos. ¿Por qué convertir las palabras de un texto en lemas? Simplemente porque en un texto el mismo términos puede aparecer varias veces en distintas formas (masculino, femenino, plural, singular,…) y la única forma de saber que es el mismo termino es lematizarlos o en nuestro caso usar un stemmer, en concreto jsEStemmer

No todos los lemas van a ser importantes. En un texto largo puedes tener montón de palabras que no estén relacionadas con el tema principal del texto. La idea es que cuantas más veces salga repetido el mismo lema más probable es que el texto trate sobre el. Así una vez extraídos todos los lemas habría que contarlos y agruparlos para quedarnos solo con los más repetidos.
Pero antes de poder extraer los lemas hay que eliminar las stopwords que son palabras que no aportan nada y son tan comunes que casi siempre saldrían como lemas principales. Al final tendrías que casi todos los textos hablan de los lemas «un», «el», «de»,.. palabras que son muy comunes y no nos aportan nada sobre el texto.

Así que el proceso sería:

  1. Eliminar los caracteres no alfabéticos y convertir las mayúsculas a minúsculas. Asi como eliminar tildes, diéresis, etc.
  2. Eliminar stopwords
  3. Lematización la palabras restantes.
  4. Agrupar lemas similares por distancia
  5. Filtrar los lemas y dejar solo los más importantes.

En el caso de nuestra librería primero obtendríamos todos los lemmas y luego los filtraríamos.

var lemmas = stemmer.stemText(text, distance);
var lemmas = stemmer.filterLemmas(lemmas, max, number, percentage);

Se pueden filtrar por:

  • max – Número máximo de lemas que debe contener la lista
  • number – Número mínimo de veces que aparece el lemma en el texto
  • percentage – Porcentaje mínimo de veces que aparece el lemma en el texto

El resultado obtenido​ estará en forma de una estructura con tres datos:

  • Un listado de los lemas «similares»
  • El número de veces que aparecen estos lemas en el texto.
  • El porcentaje que representa respecto al total de palabras del texto sin stopwords.
function Lemma(){
  this.lemmas = []; //lista de lemmas similares
  this.number = 0; //numero de repeticiones en el texto
  this.percentage = 0; //porcentaje de repeticiones en el texto

  this.merge = function(lemma){...} //permite unir dos lemmas
}

¿Como sabemos si un lema es «similar» a otro?. Calculando la distancia entre lo dos. Si son menor que un valor umbral se consideran «idénticos». Esto es necesario porque el proceso de lematización no es tan perfecto como nos gustaría y dos palabras que deberían tener  y el mismo lema pueden generar lemas muy parecidos pero no identificos. Para ello se fija un valor de corte por debajo del cual dos lemas se consideran el mismo y se agrupan bajo la misma estructura. Se suman el número de veces que aparecen y el porcentaje.

Ahora teniendo los lemas principales de un texto podemos compararlo con los lemas de cualquier otro texto, palabra o frase para ver si están relacionados.

//Calcula lo similares que son dos listados de lemas
//El resultado se calcula entre 0 y 1.
var result = stemmer.compareArrayOfLemmas((a, b, threshold));

Medir distancia entre lemas

Ya hemos visto como medir la distancia entre palabras, pero el medir distancia entre lemas plantea un problema diferente a medir el de palabras ya que solo tienen en común el texto que vaya desde el principio del lema hasta la primera letra diferente. Por ejemplo, aunque cos y cas tienen la mayor parte de las letras en común su distancia debería ser alta ya que son lemas de palabras muy diferentes. La distancia que vamos a usar es el número de caracteres en común del principio al primer carácter diferente por dos (son caracteres en común en ambos lemas) dividido entre la suma del número de caracteres de cada palabra.


function distanceLemmas(a,b){
  var shorterLength;
  var totalLength = a.length + b.length;
  if(a.length > b.length){
    shorterLength = b.length;
  } else {
    shorterLength = a.length;
  }

  var i = 0
  for(; i < shorterLength; ++i){
    if(a[i] != b[i])
      break;
  }

  if(i == 0) {
    return 1;
  } else {
    return 1-((i*2)/totalLength);
  }
};

La distancia será un resultado entre 0 y 1. 0 si todos los caracteres coinciden 1 si no coincide ninguno.

Uno de los principales motivos para calcular esta distancia es agrupar lemas similares, ya que tras al lematización los lemas obtenidos para palabras de la misma familia no siempre son el exactamente el mismo, Tras realizar varias pruebas recomendaría que se consideren similares los lemas con una distancia de hasta 0.20-0.25.

Usando la librería jsEStemmer:


var stemmer = new jsEStemmer.stemmer();
var lemma1 = stemmer.stemWord(word1);
var lemma2 = stemmer.stemWord(word2);
var distance = stemmer.distance(lemma1, lemma2);

Medir distancias entre palabras

Para medir la distancia entre dos palabras considerando solo sus caracteres e ignorando su significado. Vamos contar el número mínimo de operaciones que deberíamos realizar sobre una palabra para convertirla en la otra.

¿Que operaciones existen?:

  • Sustitución de un carácter por otro.
  • Inserción de un carácter
  • Eliminación de un carácter
  • Transposición entre dos caracteres adyacentes.

Existen varios algoritmos, según que operaciones se quieran tener en cuenta:

Distancia Sus. Ins. Eli. Tra.
Hamming X
Jaro–Winkler X
LCS X X
Levenshtein X X X
Damerau-Levenshtein X X X X

El uso de estas distancias es muy variado, por ejemplo sugerir correcciones ortográficas o agrupar palabra similares.

Hay que tener en cuanta que esto solo es una medición de la distancia comparando caracteres de dos palabras, no se tiene en cuenta su significado. Por ello aunque «remando» por significado esta más próxima a «remar» que «retar» con estas distancias «remar» y «retar» son casi iguales.

 

Lematización de palabras

Si nos pusieran la típica prueba de «¿Que palabra no está relacionada? Descubridor, descubrimiento, recubrimiento». Creo que todos acertariamos diciendo que es «recubrimiento​». Pero para un ordenador recubrimiento y descubrimiento son casi, casi la misma palabra. Al no entender el significado de las palabras no le queda más remedio que comparar sus caracteres y esa es una mala medida de similitud. Al menos de forma directa ya que en Español hay muchas terminaciones similares que hacen que dos palabras distintas tengan formas parecidas. Por ejemplo pasas con casas tienen más letra en común que con casero cuando esta claro que casero esta más relacionado con casa.

Vamos a empezar por lo más sencillo, comparar palabras. Para ello deberíamos comparar las palabras por su lema. Este proceso se llama lematización. La idea es extraer una raíz común. Hay dos principales formas de hacerlo. Usando un diccionario que asocia cada palabra con su lema. Esto requiere diccionarios enormes que es difícil que contengan todas las posibles palabras. La otra forma de hacerlo es aplicar algún algoritmo que calcule el lema. La pega es que es difícil hacer un algoritmo que calcule correctamente todos los lemas. Como casi siempre que hay dos opciones hay una tercera que es combinar ambas. Usar un diccionario y si no se encuentra ahí el lema calcularlo.

Vamos a usar esta última técnica. Para ello usaremos el algoritmo de Porter

Antes de lematización y para reducir la complejidad hay que eliminar todos los caracteres no alfabéticos. Y reemplazar la mayúscula, letras con tilde, diéresis, etc por la misma letra en minúscula.

Por ejemplo, convertiríamos «pingüino3» en «pinguino».

Sobre este resultado aplicaríamos el algoritmo de lematización.

Para los casos en que sea necesario vamos a incluir un pequeño diccionario de tal forma que podamos incluir una palabra y su lema correspondiente.

Con todo esto se ha creado la librería jsEStemmer para lemmatizar una palabra:

      var stemmer = new jsEStemmer.stemmer();
      var lemma = stemmer.stemWord(word);

 

Reconocimiento del habla en el navegador

Para el reconocimiento del habla desde el navegador usaremos la web speech API que es estandar, aunque en mis pruebas solo me ha funcionado correctamente, con algunos «peros», en Chrome. He de advertir que esta API puede enviar los audios a un servidor externo para su transcripción, lo cual debe tenerse en cuenta a nivel de seguridad y privacidad.

El primer problema que puedes tener es que para acceder a la cámara y el micro el nevegador pide autorización al usuario y solo recuerda su respuesta cuando la página se sirve desde https. No se por qué pero el reconocimiento de voz hay que reiniciarlo cada poco así que si no lo sirves desde https se hace cansado estar recargando todo el rato. Yo acabé usando caddy que permite configurar un servidor web https rápidamente.

Una vez resuelto ese problema vamos a ver el código  para inicializar el reconocimiento del habla en el navegador:

var SpeechRecognition = SpeechRecognition || webkitSpeechRecognition;

var speechRecognition = new SpeechRecognition();
speechRecognition.continuous = true;
speechRecognition.interimResults = false;
speechRecognition.lang = navigator.language || navigator.userLanguage;

speechRecognition.onresult = resultRecognition;
speechRecognition.onend = function(){
  speechRecognition.start();
};

speechRecognition.start();

 

Puedes ver todos los parámetros disponibles aquí. Como ya he comentado antes, uno de los problemas que surgen es que speechRecognition.continuous = true que se supone activa el reconocimiento continuo no va muy bien y de vez en cuando deja de funcionar, para solucionar eso en el evento onend asignamos una función que lo que hace es reiniciar el reconocimiento del habla cuando este se caiga.

En el evento onresult ponemos la función que va a tratar el resultado del reconocimiento del habla. Obtendremos un listado de cadenas de texto con un porcentaje de fiabilidad de que lo el usuario ha dicho es eso. Los resultados van ordenados de mayor a menor porcentaje. Nosotros solo vamos a usar la más probable. Pero si por ejemplo esperas un color y los resultados son: «armadillo» (90%) y «amarillo» (80%) yo apostaría por la segunda.

Con la propiedad speechRecognition.interimResults indicas cuando se lanza el evento onresult. Si es false se lanzará solo cuando el sistema de reconocimiento del habla detecte que has terminado de hablar y tenga una versión final del texto. Eso hace que a veces haya una demora entre que terminas de hablar y que se ejecute la acción. Con el valor a true lo llamará según vaya construyendo el texto. El problema es que el texto puede sufrir cambios según vaya entendiendo nuevas palabras, esos cambios pueden afectar a palabras ya detectadas. Para saber si el texto es final o no se puede consultar la propiedad isFinal de cada resultado.

En nuestro caso la función como tiene marcado que solo se le llame cuando haya terminado el reconocimiento solo tiene que pillar la solución más fiable y analizarla. Una cosa que no entiendo muy bien el motivo es que en eventos.results tienes todas las frases detectadas hasta el momento. En nuestro caso nos interesa la última. Dentro de cada uno de los elementos del array hay otro array con todas las posibles transcripciones y su fiabilidad. ¿Confuso?. Mejor vamos a ver el código:

this.resultRecognition = function(event) {
var resultIndex = event.results.length -1;
var result = event.results[resultIndex];

console.log(result[0].transcript+': '+result[0].confidence);
analyze(result[0].transcript)
};

Una vez obtenida el texto se pasa a una función nuestra para tratar de analizarlo y asociarlo al comando correspondiente. Lo primero es limpiarlo de espacios y ponerlo en minúsculas ya que a veces el texto que devuelve tiene un «original» uso de las mayúsculas

text = text.trim();
text = text.toLowerCase();

Es posible que la transcripción tenga errores. Por ejemplo en mi caso muchas veces en lugar de «chispas» reconoce «chispa» y «chispas di» es imposible que lo entienda siempre transcribe «chispas de». Lo mejor es hacer pruebas y ver cómo se comporta. Pero hay que contemplar que nuestro agente pueda interpretar diferentes versiones del comando. Mi consejo es usar expresiones regulares.

Síntesis de Voz en el navegador

En esta parte del proyecto vamos a tratar que nuestro agente pueda hablar. Para ello vamos a usar software de síntesis de voz ya sea el integrado en el propio navegador o alguna librería externa.

El don de la palabra

La comunicación oral es algo fundamental para dotar a un agente inteligente de una comunicación natural con los simples humanos. Si bien el hablar dota automáticamente a nuestro agente de un aspecto inteligente hay que tratar de no abusar de este canal de comunicación. Es cómodo y esta bien para dar información breve y concisa, pero no para leer interminables párrafos y más con el nivel actual de esta tecnología que resulta en una voz muy plana con poca o ninguna entonación.

Generalmente hay que tratar de no repetirse. Si una información que está «hablando» nuestro agente es lo mismo exactamente que se puede leer en pantalla va a resultar más molesto que otra cosa. No hay que pensar en la síntesis de voz como una alternativa a las visuales si no como un complemento que puede permitir una interacción más natural y aportar información extra. Un ejemplo perfecto en un campo donde se usan bastante aunque no nos demos cuenta (generalmente cuando algo se usa sin que nos demos cuenta es que se ha logrado integrar de forma adecuada) es en los videojuegos. El recibir asistencia e información mientras jugamos es algo habitual.

Algunos estaréis pensando en que añadiendo síntesis de voz hacéis vuestras páginas accesibles, en realidad son dos cosas diferentes. La personas con diversidad funcional ya tienen software que les ayuda y si quieres hacer la página accesible lo mejor que puedes hacer es diseñar la página correctamente siguiendo las recomendaciones de accesibilidad. Eso no quita par que un asistente virtual sea una gran ayuda.

Programando

Usar el sintetizador de voz del navegador es muy sencillo.

var utterance = new SpeechSynthesisUtterance();
utterance.text = "hola mundo";

speechSynthesis.speak(utterance);

¡Ya esta!

Nada, hemos terminado. Lo bueno si es breve dos veces bueno….Venga vale, vamos a ver un poco más.

SpeechSynthesisUtterance permite ajustar varios parámetros de la voz:

SpeechSynthesisUtterance.pitch
SpeechSynthesisUtterance.rate
SpeechSynthesisUtterance.text
SpeechSynthesisUtterance.voice
SpeechSynthesisUtterance.volume

No os asustéis si ajustáis algún parámetro y no notáis diferencia. No os estáis quedando sordos es que la implementación del estándar no es muy completa. Por ejemplo para ajustar el pitch de nuestro ejemplo:

var utterance = new SpeechSynthesisUtterance();
utterance.text = "hola mundo";
utterance.pitch = 1;
speechSynthesis.speak(utterance);

Supongo que todos los parámetros se entienden menos el de voice. ¿Como ajustamos la voz?. Para ello tenemos que usar alguna de la que nos provee el navegador. Para obtener el listado basta con llamar a:

speechSynthesis.getVoices()

Que devuelve un array con la descripción de todas las voces disponibles. Debajo pongo un ejemplo de una de las entradas del array:

default: false
lang: "es-ES"
localService: false
name: "Google español"
voiceURI: "Google español"

Los parámetros más importantes son el idioma (lang) y si es local o no. Esto último es importante porque indica que es posible que el texto se envié a un tercero, lo cual puede afectar a la privacidad y quizás habría que evitar leer datos de carácter privado.

En muchos casos en el nombre se indica si es una voz de hombre o de mujer.

Por ejemplo para cambiar la voz de nuestro ejemplo por la segunda voz seria:

var utterance = new SpeechSynthesisUtterance(); 
utterance.text = "hola mundo"; 
utterance.voice = speechSynthesis.getVoices()[1]; 
speechSynthesis.speak(utterance); 

Por defecto SpeechSynthesisUtterance se inicia con la voz del idioma en que este configurado el navegador. Si no existe una voz en ese idioma se inicia en inglés.

Interacción del usuario

Una de la limitaciones de este sistema es que necesitas la interacción del usuario. Es decir no puede hablar automáticamente nada mas cargar la web, necesita hablar como respuesta a una interacción del usuario. Por ejemplo pulsar un boton:

<html>
<body>
    <button onclick="speak()">HABLA!</button>
</body>

<script>
    function speak(){
        console.log("Speaking...");

        var utterance = new SpeechSynthesisUtterance();
        utterance.text = "hola mundo";    
        speechSynthesis.speak(utterance);
    }
</script>
</html>

Podéis estar tentados a escuchar un evento mousemove para lanzar la función speak(), no va funcionar, es necesario que el usuario haga click en la web, pulse una tecla o la pantalla (si es táctil) para poder usar el síntesis de voz.

Con eso está todo. Ya sabéis usar lo básico de la síntesis de voz en el navegador.

No todo es perfecto

Ahora siento aguar la fiesta pero no todo es tan bonito. Este sistema aún tiene varios problemas.

  • Falta de entonación. La voz es muy monótona. Carece de las inflexiones del lenguaje  hablado. Por otro lado aunque pudiera entonar habría que indicarle la entonación de alguna forma ya que el lenguaje escrito, quitando las exclamaciones e interrogaciones, no aporta información sobre la entonación y muchas veces es necesario entender el significado del texto. Una solución sería implementar SSML, en teoría están en ello.
  • En textos largos se sufren pausas impredecibles.
  • Pausas cuanto reproduces dos frases en dos llamadas a speak() seguidas. Lo ideal es que se reprodujeran unas detrás de otras sin que se note.

Aun así es un avance enorme para las interfaces habladas.

En mi canal de youtube tienes un vídeo describiendo esto mismo:

Haz click para ver el vídeo en youtube

Elegir un buen vecino

Gran parte de las metaheurísticas y heurísticas tienen el paso «Elegir un nuevo vecino/solución» pero no entran mucho en detalle de cómo elegirlo. La papeleta generalmente se resuelve eligiendo el vecino con el mejor fitness o uno aleatorio. Pero no son las únicas estrategias y dependiendo el tipo de espacio de búsqueda tampoco son las mejores. Vamos a ver algunas alternativas.

¿Qué es un vecino? La respuesta depende del espacio de búsqueda y el tipo de problema y la función usada para «generar» a los vecinos. La forma más generica de definirlo que se me ocurre seria: «Son vecinos de una solución S todas aquellas soluciones que se pueden alcanzar desde S». En algunos casos queda claro quienes son los vecinos, por ejemplo en un grafo. En otros la definición de que es un vecino se va a tener que ajustar como un parámetro más del algoritmo. Por ejemplo cuando definimos el vecino de una solución como todas aquellas soluciones que estén a una distancia igual o menor que d. En este caso d se convierte en un parámetro más a tener en cuenta.

Hay que distinguir entre dos tipos de vecindarios:

  • Poco poblados: cuando el número de vecinos es limitado y podemos calcular el fitness de todos. Suelen ser los vecindarios asociados a problemas combinacionales o a valores discretos. Eso no quiere decir que este tipo de problemas no puedan crecer tanto que se conviertan en el siguiente tipo de vecindario. Ojo al detalle de poder calcular el fitness, si la función que lo calcula es muy costosa puede resultar más rentable tratarlos como si fueran vecindarios muy poblados.
  • Muy poblados: cuando el número de vecinos es infinito o tan grande que es imposible (o su coste en tiempo es demasiado alto) recorrerlos todos. Generalmente corresponde a espacios de soluciones continuos.

Antes de empezar con estrategias  complicadas empecemos por la  y más simple y menos costosa que además funciona en ambos tipos de vecindarios. Elegir un vecino cualquiera al azar. El problema es que puede retrasar mucho la convergencia a un óptimo.

Las estrategias son distintas dependiendo del tipo de vecindario. Vamos a empezar por los vecindarios poco poblados. En este caso las estrategias más simple que se nos puede ocurrir es la de escoger el mejor: de todas las opciones buscamos la que tiene mejor fitness y la elegimos. El problema de asta estrategia es que dificulta la exploración. Pero  por otro lado reduce el tiempo que cuesta llegar al óptimo, aunque aumenta el riesgo que sea un óptimo local. Un pequeño cambio puede solucionarlo. Elegimos una al azar pero no todas tienen la misma probabilidad sino que la probabilidad de seleccionar una solución es proporcional a su fitness.

Para los vecindarios muy poblados podemos hacer un truco para seleccionar los vecinos como si fueran vecindarios poco poblados, elegimos n vecinos al azar y luego aplicamos la selección sobre esos n vecinos como si fueran los únicos del vecindario.

En el caso de espacios de soluciones continuas se suelen elegir como vecinos cualquier solución que esté a una distancia D o menor. Pero esta es la versión más simple, en lugar de elegir todos los vecinos a una distancia D con igual probabilidad podemos hacer que sea más probable seleccionar los que más cerca están o los más lejanos o los que estén a una distancia intermedia. Incluso fijar una distancia mínima. Jugando con estos valores podemos alterar el comportamiento del algoritmo (exploración/convergencia) sin modificar sus valores.

Otra forma de mejorar la selección de vecinos puede ser priorizar la dirección en la que antes ya has encontrado una buena solución esperando que sigamos mejorando en esa dirección. Por ejemplo supongamos que hemos ido del punto (5, 5) al (6, 8). Eso significa que hemos sumado el vector (1, 3). Lo primero que podríamos hacer es mirar el punto (7, 11) = (6, 8)+(1, 3). Es decir repetir el mismo paso y a partir de ahí mirar cerca de ese punto. El problema de hacerlo así es que acabamos repitiendo pasos iguales cuando realmente lo que nos interesa es solo la dirección. ¿Cómo extraerla?. Dividimos cada componente del vector por la distancia recorrida. El vector que hemos sumado ha sido (1,3). La distancia es la raíz cuadrada de la suma del cuadrado de cada elemento. SQRT(1^2+3^2) = 3.1622… Así que la dirección sería (1/3.1622, 3/1622) = (0.31, 0.93). Ya lo tenemos. Ahora podemos avanzar la distancia que queramos en esa dirección. Solo hemos de multiplicar la distancia por cada uno de los valores del vector dirección. Podemos avanzar en esta dirección hasta que deje de mejorar.

Pero estos cálculos pueden ser usados de otra manera. Si elegimos varios vecinos al azar y dividimos lo que mejora la solución entre la distancia del paso tendremos una manera de estimar en qué dirección las soluciones mejoran más rápido. Estamos calculando una aproximación del gradiante. En este caso podemos priorizar los que más rápido varíen.

Hay una versión de esta selección que se usaba originalmente para la metaheurística hill climbing que no requiere cálculos. Si tenemos una solución con múltiples coordenadas modificamos una a una hasta que deje de mejorar en esa coordenada. En el ejemplo anterior sería el caso de usar el vector dirección (1,0) hasta que deje de mejorar y luego usar (0,1). Tiene la desventaja de que puede quedarse atrapado cuando los mejores vecinos están en las diagonales.

Una última mejora es guardar un histórico de soluciones ya seleccionadas para evitar elegir una ya elegida o muy cercana (lo que sería el equivalente a andar en círculos). Según el coste en memoria de almacenarlas y en tiempo de compararlas puede ser mejor almacenar solo las últimas. Esta opción solo es válida cuando la selección del siguiente vecino es determinista, por ejemplo eligiendo el de mayor gradiante o cuando se inspeccionan todas las opciones y lo más probable es que se elija el mismo vecino la última vez que se «visitó» esa solución. Guardar información de los vecinos ya visitados no puede ahorrar «dar vueltas» y reducir el coste computacional.