Introduction

Lors de cet article, en deux parties, je vous expliquerai comment implémenter le Speech-To-Text (transformation de flux sonore en texte) avec 4 APIs différentes en Angular.

J’ai choisi d’implémenter le Speech-To-Text de :

  • Deepgram
  • Google
  • Microsoft
  • Mozilla

Ce sont 4 implémentations complètement différentes.
Le but de cet article n’est pas de vous montrer quel est le service le meilleur ou le plus efficace. C’est à vous de choisir l’API qui vous convient, selon vos critères (fournisseur déjà utilisé, prix/performance…).

Chaque API peut être implémentée avec différents langages comme Python, .NET, JavaScript, Node.js, Go et etc. J’ai pris des exemples en Node.JS ou JavaScript et je l’ai adapté pour les faire fonctionner en Angular avec TypeScript.

J’ai choisi de traiter ce sujet car étant développeuse sourde, j’utilise au quotidien les sous-titres automatiques. Je me suis intéressée aux différences entre chaque API, notamment dans les rendus (qui ne sont jamais les mêmes). J’ai développé un site qui me permet d’expérimenter ces APIs et de comprendre comment elle fonctionnent.

Ce fut une expérience très enrichissante et j’y ai appris énormément. J’ai pu partager mes connaissances et mon analyse sur ce sujet lors d’une conférence à VoxxedDays Luxembourg.

Deepgram

Commençons par l’API Deepgram.

Je me suis basée sur l’article rédigé par Deepgram (EN) pour implémenter le service Speech-To-Text avec des appels Web Sockets.

D’abord, il faut créer un compte sur Deepgram pour obtenir la clé permettant de faire fonctionner le service. A la création du compte, 150 $ de crédit est offert. Au-delà, le service devient payant.

Implémentation

Le code ci-dessous montre comment on va capturer le son de votre micro depuis votre navigateur puis envoyer le contenu de la capture en temps réel par Web Socket grâce à l’objet Media Recorder.

D’abord, on créé l’objet MediaRecorder qui permet de gérer le cycle de vie de l’enregistrement de votre audio et/ou vidéo. Grâce à lui, on va pouvoir lancer l’enregistrement ou l’arrêter avec mediaRecorder.start() ou mediaRecorder.stop(). La méthode ondataavailable permet de rendre disponible les données sous forme de blob.

Ensuite, il faut initialiser l’objet WebSocket avec l’adresse WSS et ses options. On y envoie également un tableau de protocoles dans lequel on y injecte le token avec la clé API.

Ensuite, dès que le socket est ouvert et que les données sont disponibles (grâce à l’événement dataavailable de l’objet Media Recorder), on envoie le contenu en websocket grâce à la méthode send().

 navigator.mediaDevices.getUserMedia({ audio: true }).then((stream) => {
      this.mediaRecorder = new MediaRecorder(stream, {
        mimeType: 'audio/webm',
      });

      const liveTranscriptionOptions: LiveTranscriptionOptions = {
        language: 'fr',
        version: 'latest',
        interim_results: true,
      };

      this.socket = new WebSocket('wss://api.deepgram.com/v1/listen?' + queryString.stringify(liveTranscriptionOptions), [
        'token',
        'YOUR_DEEPGRAM_API_KEY',
      ]);

      this.socket.onopen = () => {
        this.mediaRecorder.ondataavailable = async (event: BlobEvent) => {
          if (event.data.size > 0 && this.socket.readyState === 1) {
            this.socket.send(event.data);
          }
        };

        this.mediaRecorder.start(0.25);
      };

      this.socket.onmessage = (message) => {
        this.onRecognitionResult(message);
      };
    });

Puis quand on obtient une réponse du WebSocket via l’événément OnMessage, on va récupérer le texte dans la réponse.

 public onRecognitionResult(event: any): void {
    const data = JSON.parse(event.data);
    this.transcript = data.channel.alternatives[0].transcript;
  }

Tant qu’on n’arrête pas le traitement, ça va se transcrire en temps-réel. Si vous voulez arrêter le traitement, il faut fermer le websocket et arrêter l’enregistrement du mediaRecorder.

  public onStopRecognitionClick(): void {
    this.mediaRecorder.stop();
    this.socket.close();
  }

Assemblage

Si on assemble tout ça, voici ce que ça va donner.

<div class="deepgram-speech-to-text-component">
  <h1>Deepgram</h1>
  <div class="deepgram-speech-to-text-component-buttons">
    <button class="btn btn-primary" (click)="onStartRecognitionClick($event)" *ngIf="!isRecording">
      Démarrer
      <i class="fas fa-microphone"></i>
    </button>
    <button class="btn btn-secondary" (click)="onStopRecognitionClick($event)" *ngIf="isRecording">
      Arrêter<i class="fas fa-microphone-slash"></i>
    </button>
  </div>
  <div class="deepgram-speech-to-text-component-content">
    {{ textTranscripted }}
  </div>
</div>
.deepgram-speech-to-text-component {
  &-buttons {
    margin-top: 2em;
    text-align: center;
  }

  &-content {
    margin-top: 2em;
  }
}
import { Component, OnInit } from '@angular/core';
import { Title } from '@angular/platform-browser';
import * as queryString from 'query-string';

@Component({
  selector: 'app-speech-to-text-deepgram',
  templateUrl: './app-speech-to-text-deepgram.component.html',
  styleUrls: ['./app-speech-to-text-deepgram.component.scss'],
})
export class SpeechToTextDeepgramComponent implements OnInit {
  public isRecording: boolean = false;
  public textTranscripted: string;
  public transcript: string;
  public transcriptFinal: string;

  private mediaRecorder: MediaRecorder;
  private socket: WebSocket;
  private start: number;

  constructor(private titleService: Title) {
    titleService.setTitle('Deepgram Speech-To-Text');
  }

  //#region LIFE CYCLES
  public ngOnInit(): void {}
  //#endregion

  //#region EVENTS
  public onStartRecognitionClick(): void {
    this.textTranscripted = '';
    this.transcript = '';
    this.transcriptFinal = '';

    this.start = 0;
    this.isRecording = true;

    this.initRecognition();
  }

  public onRecognitionResult(event: any): void {
    const data = JSON.parse(event.data);

    if (data.start !== this.start) {
      this.start = data.start;
      this.transcriptFinal += `${this.transcript} `;
    }

    this.transcript = data.channel.alternatives[0].transcript;
    this.textTranscripted = this.transcriptFinal + this.transcript;
  }

  public onStopRecognitionClick(): void {
    this.textTranscripted = this.transcriptFinal;
    this.isRecording = false;

    this.mediaRecorder.stop();
    this.socket.close();
  }
  //#endregion

  //#region FUNCTIONS
  public initRecognition(): void {
    if (!this.isRecording) return;

    navigator.mediaDevices.getUserMedia({ audio: true }).then((stream) => {
      this.mediaRecorder = new MediaRecorder(stream, {
        mimeType: 'audio/webm',
      });

      const liveTranscriptionOptions: any = {
        language: 'fr',
        version: 'latest',
        interim_results: true,
      };

      this.socket = new WebSocket('wss://api.deepgram.com/v1/listen?' + queryString.stringify(liveTranscriptionOptions), ['token', 'YOUR_DEEPGRAM_API_KEY']);

      this.socket.onopen = () => {
        this.mediaRecorder.ondataavailable = async (event: BlobEvent) => {
          if (event.data.size > 0 && this.socket.readyState === 1) {
            this.socket.send(event.data);
          }
        };

        this.mediaRecorder.start(0.25);
      };

      this.socket.onmessage = (message) => {
        this.onRecognitionResult(message);
      };
    });
  }
  //#endregion
}

Ce qui va donner visuellement :

Capture d’écran du rendu du Speech-To-Text de Deepgram

Google

Pour Google, la transcription ne se fera pas en temps réel contrairement à Deepgram, Microsoft et Mozilla. On va enregistrer pendant qu’on parle et dès qu’on arrête l’enregistrement, ça va transcrire tout ce qu’on a dit.

Avant de commencer l’implémentation, il faut configurer le service Speech dans la console cloud de Google. Il y a trois étapes à suivre :

  • Créer votre compte sur Google Cloud Platform
  • Activer le service Cloud Speech API
  • Créer le compte de service sur la console et définir la clé d’environnement GOOGLE_APPLICATION_CREDENTIALS avec le chemin du fichier JSON contenant les informations du compte de service (fichier que vous aurez téléchargé depuis la console)

Vous pouvez retrouver des exemples d’implémentation du service Cloud Speech API en plusieurs langages (Nodejs, Python, PHP, Java, Go et .NET).

Je n’ai pas pu utiliser le package NodeJS en Angular, cela a généré de nombreuses erreurs lors de l’installation. Du coup, je me suis inspirée de la documentation Google pour faire l’implémentation en API REST.

Implémentation

L’implémentation se fait en deux étapes.

En première étape, on va lancer l’enregistrement et pendant l’enregistrement, le navigateur va capturer le son et stocker le contenu en mémoire comme on le fait déjà avec Deepgram.

navigator.mediaDevices.getUserMedia({ audio: true }).then((stream: MediaStream) => {
      this.mediaRecorder = new MediaRecorder(stream, {
        mimeType: 'audio/webm',
      });

      this.mediaRecorder.start();
});

En deuxième étape, on arrête l’enregistrement. Dès que les données sont disponibles grâce à l’événement ondataavailable, on va les convertir en format Blob en base64.

  public onStopRecognitionClick(): void {
    this.mediaRecorder.stop();

    this.mediaRecorder.ondataavailable = async (event: BlobEvent) => {
      if (event.data.size > 0) {
        this.onRecognitionResult(event.data);
      }
    };
  }

 public onRecognitionResult(blob: any): void {
    this.convertBlobToBase64(blob, (data: any) => {});     
 }

 private convertBlobToBase64(blob: Blob, callBack: any): void {
    let reader = new FileReader();

    reader.onloadend = () => {
      let dataUrl = reader.result;
      let base64 = dataUrl.toString().split(',')[1];
      callBack(base64);
    };

    reader.onerror = (err) => {
      console.error('Error in reading blob', err);
    };

    reader.readAsDataURL(blob);
  }

Avant d’envoyer ma donnée en base64, je dois supprimer le début de ma chaîne : « data:audio/webm;codecs=opus; base64, ». C’est une information que je n’ai pas besoin d’envoyer car je vais le définir dans les paramètres HTTP POST.

Avant d’envoyer ma donnée, je définis les paramètres : le type d’audio (WEBM_OPUS), la fréquence audio en hertz (48000), la langue d’audio et la valeur d’horodatage.

Puis je définis mon body en format JSON que je vais envoyer en HTTP POST avec l’objet XMLHttpRequest de manière asynchrone.

L’objet HMLHttpRequest comporte plusieurs méthodes :

  • la méthode « open » permet d’ouvrir l’appel avec l’adresse HTTP
  • la méthode « onload » permet de recevoir les résultats de mon appel et en conséquence la transcription texte de ce qui a été dit
  • la méthode « onerror » permet d’afficher les erreurs en cas d’erreur de l’appel HTTP
  • la méthode « send » permet d’envoyer le contenu du body en JSON
ublic onRecognitionResult(blob: any): void {
    this.convertBlobToBase64(blob, (data: any) => {
      const googleConfig = {
        encoding: 'WEBM_OPUS',
        sampleRateHertz: 48000,
        languageCode: 'fr-FR',
        enableWordTimeOffsets: false,
      };

      let postBody = {
        config: googleConfig,
        audio: {
          content: `${data}`,
        },
      };

      let xhr = new XMLHttpRequest();
      xhr.open('POST', `https://speech.googleapis.com/v1/speech:recognize?key=API_KEY`, true);
      xhr.onload = () => {
          let response = JSON.parse(xhr.responseText);
          if (response && response.results[0].alternatives[0].transcript) {
            this.transcript = response.results[0].alternatives[0].transcript;
          }
      };

      xhr.onerror = () => {
        console.error('Error occurred during Cloud Speech AJAX request.');
      };

      xhr.send(JSON.stringify(postBody));
    });
  }

Assemblage

Si on assemble tout ça, voici ce que ça va donner.

<div class="google-speech-to-text-component">
  <h1>Google</h1>
  <div class="google-speech-to-text-component-buttons">
    <button class="btn btn-primary" (click)="onStartRecognitionClick($event)" *ngIf="!isRecording">
      Démarrer
      <i class="fas fa-microphone"></i>
    </button>
    <button class="btn btn-secondary" (click)="onStopRecognitionClick($event)" *ngIf="isRecording">
      Arrêter<i class="fas fa-microphone-slash"></i>
    </button>
  </div>
  <p class="google-speech-to-text-component-content">
    {{ transcript }}
  </p>
</div>
.google-speech-to-text-component {
  &-buttons {
    margin-top: 2em;
    text-align: center;
  }

  &-content {
    margin-top: 2em;
  }
}
import { Component, NgZone, OnInit } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { AppConfig } from '@core/app-config';

@Component({
  selector: 'app-google',
  templateUrl: './google.component.html',
  styleUrls: ['./google.component.scss'],
})
export class GoogleComponent implements OnInit {
  public isRecording: boolean = false;
  public transcript: string = '';

  private mediaRecorder: MediaRecorder;

  constructor(private titleService: Title) {
    titleService.setTitle('Google Speech-To-Text');
  }

  //#region LIFE CYCLES
  public ngOnInit(): void {}
  //#endregion

  //#region EVENTS
  public onStartRecognitionClick(): void {
    this.isRecording = true;
    this.mediaRecorder = null;
    this.transcript = '';

    this.initRecognition();
  }

  public onRecognitionResult(blob: any): void {
    this.convertBlobToBase64(blob, (data: any) => {
      const googleConfig = {
        encoding: 'WEBM_OPUS',
        sampleRateHertz: 48000,
        languageCode: 'fr-FR',
        enableWordTimeOffsets: false,
      };

      let postBody = {
        config: googleConfig,
        audio: {
          content: `${data}`,
        },
      };

      let xhr = new XMLHttpRequest();
      xhr.open('POST', `https://speech.googleapis.com/v1/speech:recognize?key=API_KEY`, true);
      xhr.onload = () => {
          let response = JSON.parse(xhr.responseText);
          if (response && response.results[0].alternatives[0].transcript) {
            this.transcript = response.results[0].alternatives[0].transcript;
          }
      };

      xhr.onerror = () => {
        console.error('Error occurred during Cloud Speech AJAX request.');
      };

      xhr.send(JSON.stringify(postBody));
    });
  }

  public onStopRecognitionClick(): void {
    this.isRecording = false;
    this.mediaRecorder.stop();

    this.mediaRecorder.ondataavailable = async (event: BlobEvent) => {
      if (event.data.size > 0) {
        this.onRecognitionResult(event.data);
      }
    };
  }

  //#endregion

  //#region FUNCTIONS

  public initRecognition(): void {
    navigator.mediaDevices.getUserMedia({ audio: true }).then((stream: MediaStream) => {
      this.mediaRecorder = new MediaRecorder(stream, {
        mimeType: 'audio/webm',
      });

      this.mediaRecorder.start();
    });
  }

  private convertBlobToBase64(blob: Blob, callBack: any): void {
    let reader = new FileReader();

    reader.onloadend = () => {
      let dataUrl = reader.result;
      let base64 = dataUrl.toString().split(',')[1];
      callBack(base64);
    };

    reader.onerror = (err) => {
      console.error('Error in reading blob', err);
    };

    reader.readAsDataURL(blob);
  }
}

Ce qui va donner visuellement :

Conclusion

Dans le prochain article, je vous montrerais comment implémenter le Speech-To-Text avec les APIs de Microsoft et Mozilla et vous ferais une synthèse des pour et contre de chaque implémentation.

En attendant, vous pouvez trouver les sources sur https://github.com/emma11y/speech-to-text dans le dossier example-article.

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.