Cómo agregar texto a voz con la API de síntesis de voz HTML5

Cómo usar la API de síntesis de voz HTML5 para agregar texto a voz a su aplicación con múltiples opciones de voz.

Primeros pasos

Para este tutorial, vamos a utilizar el marco JavaScript de pila completa de CheatCode, Joystick. Joystick reúne un marco de interfaz de usuario de front-end con un back-end de Node.js para crear aplicaciones.

Para comenzar, querremos instalar Joystick a través de NPM. Asegúrese de estar usando Node.js 16+ antes de instalar para garantizar la compatibilidad (lea este tutorial primero si necesita aprender a instalar Node.js o ejecutar varias versiones en su computadora):

Terminal

npm i -g @joystick.js/cli

Esto instalará Joystick globalmente en su computadora. Una vez instalado, vamos a crear un nuevo proyecto:

Terminal

joystick create app

Después de unos segundos, verá un mensaje desconectado de cd en su nuevo proyecto y ejecute joystick start :

Terminal

cd app && joystick start

Después de esto, su aplicación debería estar ejecutándose y estamos listos para comenzar.

Agregar Bootstrap

Profundizando en el código, primero, queremos agregar el marco Bootstrap CSS a nuestra aplicación. Mientras no tienes para hacer esto, hará que nuestra aplicación se vea un poco más bonita y evitará que tengamos que mezclar CSS para nuestra interfaz de usuario. Para hacerlo, agregaremos el enlace CDN de Bootstrap al /index.html archivo en la raíz de nuestro proyecto:

/index.html

<!doctype html>
<html class="no-js" lang="en">
  <head>
    <meta charset="utf-8">
    <title>Joystick</title>
    <meta name="description" content="An awesome JavaScript app that's under development.">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="theme-color" content="#FFCC00">
    <link rel="apple-touch-icon" href="/apple-touch-icon-152x152.png">
    <link rel="stylesheet" href="/_joystick/index.css">
    <link rel="manifest" href="/manifest.json">
    <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous">
    ${css}
  </head>
  <body>
    ...
  </body>
</html>

Aquí, justo encima del ${css} parte en el archivo, hemos pegado en el <link></link> etiqueta de la documentación de Bootstrap que nos da acceso a la parte CSS del marco.

Eso es todo. Joystick se reiniciará automáticamente y cargará esto en el navegador para que podamos comenzar a usarlo.

Conexión de un componente Joystick con texto a voz

En una aplicación Joystick, nuestra interfaz de usuario se crea utilizando la biblioteca de interfaz de usuario integrada del marco @joystick.js/ui . Cuando ejecutamos joystick create app arriba, nos dieron algunos componentes de ejemplo para trabajar. Vamos a sobrescribir el /ui/pages/index/index.js archivo con algo de HTML que servirá como interfaz de usuario para nuestro traductor.

/ui/pages/index/index.js

import ui from '@joystick.js/ui';

const Index = ui.component({
  css: `
    h4 {
      border-bottom: 1px solid #eee;
      padding-bottom: 20px;
      margin-bottom: 40px;
    }

    textarea {
      margin-bottom: 40px;
    }
  `,
  render: () => {
    return `
      <div>
        <h4>Text to Speech Translator</h4>
        <form>
          <textarea class="form-control" name="textToTranslate" placeholder="Type the text to speak here and then press Speak below."></textarea>
          <button class="btn btn-primary">Speak</button>
        </form>
        <div class="players"></div>
      </div>
    `;
  },
});

export default Index;

Para empezar, queremos reemplazar el componente que estaba en este archivo con lo que vemos arriba. Aquí, estamos definiendo un componente simple con dos cosas:un render función que devuelve una cadena de HTML que queremos mostrar en el navegador y encima una cadena de css que queremos aplicar al código HTML que estamos renderizando (Joystick aplica automáticamente el CSS que pasamos aquí al código HTML devuelto por nuestro render función).

Si cargamos http://localhost:2600 en un navegador (puerto 2600 es donde Joystick comienza por defecto cuando ejecutamos joystick start ), deberíamos ver la versión estilo Bootstrap del HTML anterior.

/ui/pages/index/index.js

import ui from '@joystick.js/ui';

const Index = ui.component({
  events: {
    'submit form': (event, component) => {
      event.preventDefault();

      const text = event?.target?.textToTranslate?.value;
      const hasText = text.trim() !== '';

      if (!hasText) {
        return component.methods.speak('Well you have to say something!');
      }

      component.methods.speak(text);
    },
  },
  css: `...`,
  render: () => {
    return `
      <div>
        <h4>Text to Speech Translator</h4>
        <form>
          <textarea class="form-control" name="textToTranslate" placeholder="Type the text to speak here and then press Speak below."></textarea>
          <button class="btn btn-primary">Speak</button>
        </form>
        <div class="players"></div>
      </div>
    `;
  },
});

export default Index;

A continuación, queremos agregar un events objeto a nuestro componente. Como su nombre lo indica, aquí es donde definimos los detectores de eventos para nuestro componente. Aquí, estamos definiendo un oyente para el submit evento en el <form></form> elemento representado por nuestro componente. Al igual que nuestro CSS, Joystick ajusta automáticamente el alcance de nuestros eventos al HTML que se está representando.

Asignado a ese submit form propiedad en nuestro events object es una función que se llamará cada vez que se detecte el evento de envío en nuestro <form></form> .

Dentro de esa función, primero, tomamos el event (este es el evento DOM del navegador) como primer argumento e inmediatamente llame a event.preventDefault() en eso. Esto evita que el navegador intente realizar un HTTP POST al action atributo en nuestro formulario. Como sugiere el nombre, este es el predeterminado comportamiento para navegadores (no tenemos un action atributo en nuestro formulario ya que queremos controlar el envío a través de JavaScript).

Luego, una vez que esto se detenga, queremos obtener el valor ingresado en nuestro <textarea></textarea> . Para hacerlo, podemos hacer referencia al textToTranslate propiedad en el event.target objeto. Aquí, event.target se refiere al <form></form> elemento tal como se muestra en el navegador (está en representación de memoria).

Podemos acceder a textToTranslate porque el navegador asigna automáticamente todos los campos dentro de un formulario en la memoria usando el name del campo atributo como el nombre de la propiedad. Si miramos de cerca nuestro <textarea></textarea> , podemos ver que tiene el name atributo textToTranslate . Si cambiamos esto a pizza , escribiríamos event?.target?.pizza?.value en su lugar.

Con ese valor almacenado en el text variable, a continuación, creamos otra variable hasText que contiene una verificación para asegurarse de que nuestro text variable no es una cadena vacía (el .trim() parte aquí "recorta" cualquier carácter de espacio en blanco en caso de que el usuario presione la barra espaciadora una y otra vez).

Si no tenemos ningún texto en la entrada, queremos "decir" la frase "¡Bueno, tienes que decir algo!" Asumiendo que hicimos obtener un mensaje de texto, solo queremos "decir" que text valor.

Tenga en cuenta que aquí estamos llamando a component.methods.speak que aún no hemos definido. Aprovecharemos el methods de Joystick característica (donde podemos definir funciones misceláneas en nuestro componente).

/ui/pages/index/index.js

import ui from '@joystick.js/ui';

const Index = ui.component({
  methods: {
    speak: (text = '') => {  
      window.speechSynthesis.cancel();

      const message = new SpeechSynthesisUtterance(text);

      speechSynthesis.speak(message);
    },
  },
  events: {
    'submit form': (event, component) => {
      event.preventDefault();

      const text = event?.target?.textToTranslate?.value;
      const hasText = text.trim() !== '';

      if (!hasText) {
        return component.methods.speak('Well you have to say something!');
      }

      component.methods.speak(text);
    },
  },
  css: `...`,
  render: () => {
    return `
      <div>
        <h4>Text to Speech Translator</h4>
        <form>
          <textarea class="form-control" name="textToTranslate" placeholder="Type the text to speak here and then press Speak below."></textarea>
          <button class="btn btn-primary">Speak</button>
        </form>
        <div class="players"></div>
      </div>
    `;
  },
});

export default Index;

Ahora viene la parte divertida. Debido a que la API de síntesis de voz se implementa en los navegadores (ver compatibilidad aquí, es bastante buena), no tenemos que instalar ni importar nada; toda la API es accesible globalmente en el navegador.

Agregar un methods objeto justo encima de nuestro events , estamos asignando el speak método al que llamamos desde nuestro submit form controlador de eventos.

En el interior, no hay mucho que hacer:

  1. En caso de que cambiemos el texto que hemos escrito y hagamos clic en el botón "Hablar" en medio de la reproducción, queremos llamar al window.speechSynthesis.cancel() método para decirle a la API que borre su cola de reproducción. Si no hacemos esto, simplemente agregará la reproducción a su cola y continuará reproduciendo lo que le pasamos (incluso después de una actualización del navegador).
  2. Crear una instancia de SpeechSynthesisUtterance() que es una clase que toma el texto que queremos hablar.
  3. Pase esa instancia al speechSynthesis.speak() método.

Eso es todo. Tan pronto como escribamos algo de texto en el cuadro y presionemos "Hablar", su navegador (suponiendo que sea compatible con la API) debería comenzar a parlotear.

Impresionante. Pero no hemos terminado. Lo creas o no, la API de síntesis de voz también incluye la opción de usar diferentes voces. A continuación, actualizaremos el HTML devuelto por nuestro render función para incluir una lista de voces para elegir y actualizar methods.speak para aceptar diferentes voces.

/ui/pages/index/index.js

import ui from '@joystick.js/ui';

const Index = ui.component({
  state: {
    voices: [],
  },
  lifecycle: {
    onMount: (component) => {
      window.speechSynthesis.onvoiceschanged = () => {
        const voices = window.speechSynthesis.getVoices();
        component.setState({ voices });
      };
    },
  },
  methods: {
    getLanguageName: (language = '') => {
      if (language) {
        const regionNamesInEnglish = new Intl.DisplayNames(['en'], { type: 'region' });
        return regionNamesInEnglish.of(language?.split('-').pop());
      }

      return 'Unknown';
    },
    speak: (text = '', voice = '', component) => {  
      window.speechSynthesis.cancel();

      const message = new SpeechSynthesisUtterance(text);

      if (voice) {
        const selectedVoice = component?.state?.voices?.find((voiceOption) => voiceOption?.voiceURI === voice);
        message.voice = selectedVoice;
      }

      speechSynthesis.speak(message);
    },
  },
  events: {
    'submit form': (event, component) => {
      event.preventDefault();
      const text = event?.target?.textToTranslate?.value;
      const voice = event?.target?.voice?.value;
      const hasText = text.trim() !== '';

      if (!hasText) {
        return component.methods.speak('Well you have to say something!', voice);
      }

      component.methods.speak(text, voice);
    },
  },
  css: `
    h4 {
      border-bottom: 1px solid #eee;
      padding-bottom: 20px;
      margin-bottom: 40px;
    }

    select {
      margin-bottom: 20px;
    }

    textarea {
      margin-bottom: 40px;
    }
  `,
  render: ({ state, each, methods }) => {
    return `
      <div>
        <h4>Text to Speech Translator</h4>
        <form>
          <label class="form-label">Voice</label>
          <select class="form-control" name="voice">
            ${each(state?.voices, (voice) => {
              return `
                <option value="${voice.voiceURI}">${voice.name} (${methods.getLanguageName(voice.lang)})</option>
              `;
            })}
          </select>
          <textarea class="form-control" name="textToTranslate" placeholder="Type the text to speak here and then press Speak below."></textarea>
          <button class="btn btn-primary">Speak</button>
        </form>
        <div class="players"></div>
      </div>
    `;
  },
});

export default Index;

Para agilizarnos, hemos generado el resto del código que necesitaremos arriba, analicemos paso a paso.

Primero, para obtener acceso a las voces disponibles que ofrece la API, debemos esperar a que se carguen en el navegador. Por encima de nuestro methods opción, hemos agregado otra opción a nuestro componente lifecycle y le hemos asignado un onMount() función.

Joystick llama a esta función inmediatamente después de montar nuestro componente en el DOM. Es una buena manera de ejecutar código que depende de la interfaz de usuario o, como en este caso, una forma de escuchar y manejar eventos globales o de nivel de navegador (a diferencia de los eventos generados por el HTML representado por nuestro componente).

Sin embargo, antes de que podamos obtener las voces, debemos escuchar el window.speechSynthesis.onvoiceschanged evento. Este evento se activa tan pronto como se cargan las voces (estamos hablando de fracciones de segundo, pero lo suficientemente lento como para que queramos esperar en el nivel del código).

Dentro de onMount , asignamos ese valor a una función que se llamará cuando el evento se active en el window . Dentro de esa función, llamamos al window.speechSynthesis.getVoices() función que nos devuelve una lista de objetos que describen todas las voces disponibles. Para que podamos usar esto en nuestra interfaz de usuario, tomamos el component argumento pasado al onMount función y llame a su setState() función, pasando un objeto con la propiedad voices .

Porque queremos asignar un valor de estado voices al contenido de la variable const voices aquí, podemos omitir escribir component.setState({ voices: voices }) y solo usa la versión abreviada.

Importante :arriba del lifecycle opción, observe que hemos agregado otra opción state establecido en un objeto y en ese objeto, una propiedad voices establecido en una matriz vacía. Este es el valor predeterminado para nuestro voices matriz, que entrará en juego a continuación en nuestro render función.

Allí, podemos ver que hemos actualizado nuestro render función para usar la desestructuración de JavaScript para que podamos "quitar" las propiedades del argumento que se pasa, la instancia del componente, para usar en el HTML que devolvemos desde la función.

Aquí, estamos jalando state , each y methods . state y methods son los valores que establecimos arriba en el componente. each es lo que se conoce como una "función de renderizado" (que no debe confundirse con la función asignada al render opción en nuestro componente).

Como sugiere el nombre, each() se usa para recorrer o iterar sobre una lista y devolver algo de HTML para cada elemento de esa lista.

Aquí, podemos ver el uso de la interpolación de cadenas de JavaScript (indicado por el ${} entre la apertura y el cierre del <select></select> etiqueta) para pasar nuestra llamada a each() . A each() , pasamos la lista o array (en este caso, state.voices ) como primer argumento y para el segundo, una función que será llamada, recibiendo el valor actual que se itera.

Dentro de esta función, queremos devolver algo de HTML que se generará para cada uno elemento en el state.voices matriz.

Porque estamos dentro de un <select></select> etiqueta, queremos representar una opción de selección para cada una de las voces que obtuvimos de la API de síntesis de voz. Como mencionamos anteriormente, cada voice es solo un objeto de JavaScript con algunas propiedades. Los que nos interesan aquí son los voice.voiceURI (el ID/nombre único de la voz) y voice.name (el nombre literal del hablante).

Finalmente, también nos preocupamos por el idioma que se habla. Esto se pasa como lang en cada voice objeto en forma de un código de idioma ISO estándar. Para obtener la representación "amigable" (por ejemplo, France o Germany ), necesitamos convertir el código ISO. Aquí, estamos llamando a un método getLanguageName() definido en nuestro methods objeto que toma el voice.lang value y lo convierte en una cadena amigable para los humanos.

Mirando esa función arriba, tomamos language como argumento (la cadena que pasamos desde dentro de nuestro each() ) y si no es un valor vacío, cree una instancia de Intl.DisplayNames() clase (Intl es otro global disponible en el navegador), pasándole una serie de regiones que queremos admitir (dado que el autor es un yank, solo en ) y en las opciones del segundo argumento, poniendo el nombre type a "región".

Con el resultado de esto almacenado en regionNamesInEnglish , llamamos al .of() de esa variable método, pasando el language argumento pasado a nuestra función. Cuando lo pasamos, llamamos al .split('-') en él para decir "dividir esta cadena en dos en el - carácter (es decir, si pasamos en-US obtendríamos una matriz como ['en', 'US'] ) y luego, en la matriz resultante, llama al .pop() método para decir "quita el último artículo y devuélvelo". En este caso, el último elemento es US como una cadena que es el formato previsto por el .of() método.

Sólo un paso más. Observe que en nuestro submit form controlador de eventos, hemos agregado una variable para el voice opción (usando la misma técnica para recuperar su valor como lo hicimos para textToTranslate ) y luego páselo como segundo argumento a nuestro methods.speak() función.

De vuelta en esa función, agregamos voice como segundo argumento junto con component como el tercero (Joystick pasó automáticamente component como el último argumento de nuestros métodos; sería el primero si no se pasaran argumentos o, en este ejemplo, el tercero si se pasaran dos argumentos).

Dentro de nuestra función, hemos agregado un if (voice) verifica y dentro de eso, ejecutamos un .find() en el state.voices matriz para decir "encuéntrenos el objeto con un .voiceURI valor igual al voice argumento que pasamos al speak función (esta es la función en-US cadena o voice.lang ). Con eso, simplemente establecemos .voice en nuestro message (el SpeechSynthesisUtterance instancia de clase) y la API toma el control desde allí.

¡Hecho! Si todo está en su lugar correcto, deberíamos tener un traductor de texto a voz que funcione.

Terminando

En este tutorial, aprendimos cómo escribir un componente usando el @joystick.js/ui framework para ayudarnos a construir una API de texto a voz. Aprendimos cómo escuchar eventos DOM y cómo aprovechar la API de síntesis de voz en el navegador para hablar por nosotros. También aprendimos sobre el Intl biblioteca integrada en el navegador para ayudarnos a convertir un código ISO para una cadena de fecha en un nombre amigable para los humanos. Finalmente, aprendimos cómo cambiar de voz dinámicamente a través de la API de síntesis de voz para admitir diferentes tonos e idiomas.