Arquitectura de un bot que interactúa por voz

Vamos a ver como crear nuestro propio bot que interactue usando la voz. Algo, salvando las distancias, al estilo Siri, Alexa o Cortana. En esta entrada voy a hablar solo de la arquitectura y no del procesamiento del lenguaje natural o de la síntesis del mismo.

Lo primero que necesita todo bot es estar escuchando a la espera de oír su nombre o alguna frase que pueda reconocer como su orden. Aunque hay implementaciones por hardware que esperan oír el nombre del bot para activar la captura de audio, en nuestro caso deberemos estar escuchando constantemente. Así que nuestro primer componente va ser un proceso que escucha constantemente y transforma el audio en texto.

Posteriormente ese texto ha de ser analizado por un sistema de reglas que determine si el audio contiene algún comando valido, en nuestro caso el sistema de reglas estará basado en expresiones regulares.

Una vez tengamos una coincidencia valida se tienen que extraer los datos del comando y ejecutar el código asociado.

El resultado de la ejecución de ese código ha de ser transformado a una frase de texto que sea entendible por el usuario.

Finalmente ese texto será leído en voz alta por nuestro bot. Por lo que necesitaremos un sistema que sintetice la voz a partir del texto.

Los navegadores modernos incluyen una API tanto de reconocimiento del habla como de síntesis del habla . Aunque la verdad que el único que les da un soporte decente es Chrome, de hecho es el único en que logré que funcionara en español.  Con eso tenemos parte de lo que necesita un bot, falta una forma de definir los comando validos y otra de componer las frases que el bot necesite decir. Para ello, y alguna cosa más, se ha creado la librería jsBotVoice

En resumen:

  1. Captura del audio
  2. Conversión a texto
  3. Análisis del texto en busca de coincidencias con los comandos de texto definidos
  4. Extracción de datos y ejecución del código asociado al comando
  5. Creación del texto en respuesta
  6. Síntesis de voz a partir del texto
botVoice

Diagrama de funcionamiento de un bot que se comunica por voz

 

Los puntos del 1 al 4 están explicados en esta entrada del blog y del 5 al 6 en esta otra.

Síntesis de voz y lenguaje natural en un bot

En una entrada anterior veíamos como interpretar los comandos de voz. Ahora vamos a ver hacer que el bot de respuesta lo más naturales posibles en lenguaje hablado.

La forma más sencilla y rápida es tener respuestas predefinidas. Lo malo de este sistema es que la respuesta es siempre igual y queda muy poco natural. Usando la librería jsBotVoice  es tan sencillo como usar el modificador * delante de la cadena y esa cadena será enunciada literalmente.

voice.talk("*Este texto se lee literal");

Para aportar algo de variedad a la respuesta vamos a utilizar un modelo muy simple basado en diccionarios. La idea es que cada palabra de la frase pueda reemplazarse por varias expresiones similares, eligiéndose una al azar lo cual dota al bot de variedad en las respuestas. Lo primero será definir el diccionario.

var spanish_dictionary = {
  "hi": ["hola", "saludos"],
  "person": ["humano","ser"],
  "yes": ["si","afirmativo"],
  "no": ["no","negativo"],
  "ok": ["vale", "de acuerdo", "hecho"]
};

Una vez definido para usarlo solo hemos de crear una frase con esos tokens

voice.talk("hi person");

Esta linea pueda dar varios posibles resultados:

hola humano
saludos humano
hola ser
saludos ser

Si queremos aladir alguna palabra que se lea de forma literal en lugar de interpretarla como un token podemos ponerle delante el modificador #

voice.talk("#Buenas person");

Posibles resultados:
buenas humano
buenas ser

Por otro lado podemos personalizar aun más las respuestas usando variables, para ello usamos el nombre de la variable precedido de un $ . Las variables han de ir almacenadas en el mapa voice.data

voice.data["name"] = "Cubiwan";
voice.talk("hi $name");

Posibles resultados:
hola cubiwan
saludos cubiwan

Más documentación y ejemplos en jsBotVoice .

Interpretar lenguaje natural en un bot usando expresiones regulares

En una entrada anterior hemos visto como capturar voz en el navegador y convertirla en mensajes de texto. Ahora nos toca tratar esos mensajes para poder lanzar el comando adecuado. Cómo realmente lo único que necesitamos entender son comandos muy simples y predefinidos el sistema va a ser relativamente sencillo. Para procesar textos más complejos exiten sistemas más avanzados que requiren procesamiento del lenguaje natural. Estos sistemas requieren un conocimiento previo del lenguaje que van a procesar tanto por parte de la libreria como por parte del programador. Las expresiones regulares son sencillas de definir por el programador y para un sistema tan simple funcionan perfectamente.

En esta entrada usaremos la librería jsBotVoice que ayuda a usar expresiones regulares para interpretar los comandos.

Por lo general los bots habitualmente funcionan definiendo varias combinaciones de frases por cada comando precedidas del nombre del bot, «chispas» en nuestro caso. Por ejemplo las frases para preguntarnos la hora pueden ser:

«¿Qué hora es?»
«Dime la hora»
«Di la hora»
«¿Me dices la hora?»
«Hora»

Sin contar que puede ir precedida de cosas como. «Por favor».

Para facilitar el trabajo la mayoría de las librerías llevan herramientas para reducir el número de reglas necesarias permitiendo agrupar varias expresiones bajo una misma regla. En este caso vamos a usar expresiones regulares. Para ello dividiremos cada expresión en tokens que van asociados a expresiones regulares que describen su parte de la frase.

En nuestro caso el modelo sería:

bot please# tell time any

Cada token se traduciría en las siguientes expresiones regulares:

bot: chispa|chispas
please:por favor|porfa
tell: que|di[a-z]|me dices
time: la hora|hora
any: .

El problema es que al usar expresiones regulares tan poco concretas reconoceremos como válidas expresiones incorrectas como:

«chispas divaga hora y media»

En este caso podriamos añadir di|dim|dinos en lugar de di[a-z]*.

Hay que buscar un equilibrio entre flexibilidad de las expresiones y concretar los resultados.

Hemos logrado traducir todas las maneras de decirlo en una sola expresión pero si necesitáramos más no habría problema. Por ejemplo supongamos que queremos añadir:

«¿En que punto de la corriente temporal me encuentro?»

Podemos añadirla directamente por ejemplo:

bot: chispa|chispas|chispita
please:por favor|porfa
tell: en que|que|di[a-z]|me dices
time: la hora|hora
any: .

temporalPoint: punto de la corriente temporal

Que se traduciría en un nuevo comando de voz:

bot please# tell temporalPoint any

Vamos a ponernos con el código, primero definir los tokens:

var tokens = {
  'bot': 'chispa|chispas|chispita',
  'please': 'por favor|porfa',
  'tell': 'que|di[a-z]*|me dices',
  'time': 'la hora|hora',
  'any': '.*',
  'temporalPoint': 'punto de la corriente temporal'
}

Ahora con esos tokens podemos definir las expresiones que el bot va a entender


var vc1 = new VoiceCommand();
vc1.name="tell me time";
vc1.expressions[0] = "bot please# tell time any";
vc1.expressions[1] = "bot please# tell TemporalPoint any";
vc1.execute = function(exp, m, voice) {
 sayTime();
};

Cuando nuestro bot oiga un comando de voz que encaje con las expresiones definidas ejecutara el comando definido en execute al que se le pasa como parámetros:

  • exp: el indice de la expresión que ha coincidido
  • m: array con la coincidencia de texto de cada token de la expresión. m[0] incluye todo el texto
  • voice: referencia al al objeto Voice

Para iniciar el bot a escuchar basta con ejecutar init.

voice.init();

Pero si se quiere probar como reconoce las frases sin iniciar la parte de reconocer el audio puede usarse la función analyze pasando el texto a analizar.

voice.analyze(text);

Hay que tener en cuenta que hay que realizar pruebas reales para detectar errores habituales en el reconocimiento del habla. Por ejemplo en el caso del nombre del bot (bot: chispa|chispas) en se ha incluido chispa porque una gran cantidad de veces se reconocia la palabra «chispas» como si fuera «chispa».

En la web de la librería se puede encontrar más información.

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