Transcription live avec Twilio Media Streams, Azure cognitive Services et Java

June 01, 2021
Rédigé par
Révisé par

Transcription en direct avec Twilio Media Streams, Azure cognitive Services et Java

Twilio Media Streams peut être utilisé pour diffuser des données audio en temps réel d'un appel téléphonique vers votre serveur à l'aide de WebSockets. Associé à un système de synthèse vocale, il peut être utilisé pour générer une transcription en temps réel d'un appel téléphonique. Dans ce post, je vais vous montrer comment configurer un serveur Java WebSocket pour gérer les données audio de Twilio Media Streams et utiliser Azure cognitive Services Speech pour la transcription.

Prérequis

Pour suivre ce tutoriel, vous devez disposer des éléments suivants :

Si vous souhaitez passer à l'étape suivante, vous pouvez trouver le code complété dans mon répertoire sur GitHub.

Mise en route

Pour qu'un projet Web Java soit rapidement opérationnel, je vous recommande d'utiliser Spring InitializrCe lien vous permet de paramétrer toutes les configurations nécessaires pour ce projet. Cliquez sur « Generate » (Générer) pour télécharger le projet, puis décompressez-le et ouvrez le projet dans votre IDE.

Il y aura un seul fichier source Java dans src/main/Java/com/example/twilio/mediastreamsazuretranscription intitulé MediaStreamsAzureTranscriptionApplication.java. Vous n'aurez pas besoin de modifier ce fichier, mais il contient une méthode main que vous pourrez utiliser ultérieurement pour exécuter le code.

Répondre à un appel téléphonique et lancer la diffusion multimédia en continu

Pour commencer, créons un code qui requêtera à Twilio de répondre à un appel téléphonique, de réciter un court message, puis de lancer un flux multimédia que nous utiliserons pour effectuer la transcription. Twilio va diffuser des données audio binaires sur une URL que nous fournissons, et nous les enverrons sur Azure pour transcription.

Pour commencer, nous devons créer un point de terminaison HTTP qui servira le TwiML suivant sur /twiml :

<Response>
  <Say>Bonjour ! Commencez à parler et l'audio sera transmis en live à votre application</Say>
  <Start>
    <Stream url="WEBSOCKET_URL"/>
  </Start>
  <Pause length="30"/>
</Response>

Avant de commencer à coder, voyons ce TwiML en détail pour voir ce qui se passe :

  • Le verbe <Say> (Dire) utilise la synthèse vocale pour lire le message « Hello ! » (Bonjour) à voix haute.
  • Ensuite, démarrez (<Start>) un flux multimédia (<Stream>) vers une URL WebSocket (nous verrons comment créer cette URL ultérieurement).
  • Enfin, mettez en pause (<Pause>) pendant 30 secondes de temps de transcription, puis raccrochez pour mettre fin à l'appel et à la transcription.

Tout d'abord, ajoutez la bibliothèque Twilio Helper au projet. Il s'agit d'un projet Gradle. Il y a donc un fichier intitulé build.gradle dans la racine du projet avec une section dependencies à laquelle vous devez ajouter :

implementation 'com.twilio.sdk:twilio:8.12.0'

[voir le code sur GitHub]

Nous vous recommandons toujours d'utiliser la dernière version de la bibliothèque Twilio Helper. La dernière version est la version 8.12.0, mais de nouvelles versions sont publiées fréquemment. Vous pouvez toujours vérifier la dernière version à l'adresse mvnreporistory.com.

Dans le même package que la classe MediaStreamsAzureTranscriptionApplication, créez une classe intitulée TwimlRestController avec le code suivant :

@Controller
public class TwimlRestController {

    @PostMapping(value = "/twiml", produces = "application/xml")
    @ResponseBody
    public String getStreamsTwiml() {

        String wssUrl = "WEBSOCKET_URL";

        return new VoiceResponse.Builder()
            .say(new Say.Builder("Bonjour ! Commencez à parler et l'audio sera transmis en live à votre application").build())
            .start(new Start.Builder().stream(new Stream.Builder().url(wssUrl).build()).build())
            .pause(new Pause.Builder().length(30).build())
            .build().toXml();
    }
}

[ce code avec les instructions d'importation sur GitHub]

Vous pouvez voir que l'URL WebSocket est actuellement codée en dur. Cela ne nous convient pas.

Construire l'URL WebSocket

La même application que nous construisons pour gérer les requêtes HTTP traitera également les requêtes WebSocket de Twilio. Les URL WebSocket ressemblent beaucoup aux URL HTTP, mais au lieu de https://hostname/path, nous avons besoin de wss://hostname/path.

Pour créer l'URL WebSocket, nous avons besoin d'un nom d'hôte et d'un chemin. Pour le chemin, nous pouvons choisir tout ce que nous voulons (nous allons utiliser /messages), mais le nom d'hôte nécessite un peu plus de travail. Nous pourrions le coder en dur, mais nous devons ensuite modifier le code à chaque fois que nous déployons quelque chose de nouveau. Une meilleure approche consiste à inspecter la requête HTTP vers /twiml pour voir le nom d'hôte qui y a été utilisé. Nous le trouverons dans l'en-tête de l'hôte. La requête HTTP complète ressemble à ceci :

POST /twiml HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
Content-Length: 0
Host: localhost:8080
User-Agent: HTTPie/0.9.8

Il est possible que nous déployions cette application derrière un proxy ou une passerelle API, ce qui peut nécessiter de se faufiler et de modifier la valeur de l'en-tête de l'hôte. Ngrok (que je vais utiliser plus tard dans ce tutoriel) est un de ces proxys. Nous devons donc également vérifier l'en-tête X-Original-Host qui sera défini si Host a été modifié. Certains proxys l'appellent X-Forwarded-Host ou même autrement, mais il s'agit de la même chose. Dans ce cas, une requête HTTP peut ressembler à ceci :

POST /twiml HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
Content-Length: 0
Host: localhost:8080
X-Original-Host: be136ff2eaca.ngrok.io
User-Agent: HTTPie/0.9.8

Pour une requête comme celle-ci, le nom d'hôte dans l'URL wss:// doit être be136ff2eaca.ngrok.io. Maintenant que nous savons comment construire l'URL WebSocket, voyons-la dans le code. Remplacez le TwimlRestController par ce qui suit :

    @PostMapping(value = "/twiml", produces = "application/xml")
    @ResponseBody
    public String getStreamsTwiml(@RequestHeader(value = "Host") String hostHeader,
                                  @RequestHeader(value = "X-Original-Host", required = false) String originalHostname) {

        String wssUrl = createWebsocketUrl(hostHeader, originalHostname);
        
        return new VoiceResponse.Builder()
            .say(new Say.Builder("Bonjour ! Commencez à parler et l'audio sera transmis en live à votre application").build())
            .start(new Start.Builder().stream(new Stream.Builder().url(wssUrl).build()).build())
            .pause(new Pause.Builder().length(30).build())
            .build().toXml();
    }


    private String createWebsocketUrl(String hostHeader, String originalHostHeader) {

        String publicHostname = originalHostHeader;
        if (publicHostname == null) {
            publicHostname = hostHeader;
        }

        return "wss://" + publicHostname + "/messages";
    }

[ce code avec les importations sur GitHub]

À vérifier rapidement...

Vérifiez que tout fonctionne comme prévu en exécutant la méthode main dans MediaStreamsAzureTranscriptionApplication via votre IDE ou en exécutant ./gradlew clean bootRun sur la ligne de commande. L'application démarre et vous pouvez utiliser Curl ou tout autre outil HTTP pour envoyer une requête POST à http://localhost:8080/twiml. Mon outil de choix pour ce genre de choses est HTTPie. Notez que l'URL WebSocket dans la réponse sera wss://localhost:8080/messages, car votre client a placé Host: Localhost:8080 comme en-tête lorsqu'il a effectué la requête. C'est l'idéal.

Gérer les connexions WebSocket

Il est bien d'avoir une URL wss:// dans votre TwiML, mais nous devons vraiment y ajouter du code pour traiter les requêtes WebSocket de Twilio. Autrement, il ne s'agit que d'un lien 404. Dans le même package, créez à nouveau une classe intitulée TwilioMediaStreamsHandler avec ce contenu :

public class TwilioMediaStreamsHandler extends AbstractWebSocketHandler {

    @Override
    public void afterConnectionEstablished(WebSocketSession session) {
        System.out.println("Connection Established");
    }

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) {
        System.out.println("Message");
    }

    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
        System.out.println("Connection Closed");
    }
}

[ce code avec les importations sur GitHub]

Nous avons également besoin d'une classe de configuration. Appelez-la WebSocketConfigdans le même package :

@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(new TwilioMediaStreamsHandler(), "/messages").setAllowedOrigins("*");
    }
}

[ce code avec les importations sur GitHub]

La prochaine chose à mettre en œuvre est de connecter ce système à Azure.

Avec une touche d'Azure...

Vous pouvez obtenir une bonne vue d'ensemble du service Azure Speech-to-Text à partir de sa documentation. Il s'agit d'un service de grande envergure et il existe de nombreuses façons de l'utiliser. Dans tous les cas, vous aurez besoin d'un compte Azure. Inscrivez-vous ici si vous n'en avez pas déjà un. Ce projet s'adapte confortablement au niveau gratuit et à ses 5 heures par mois. Suivez les instructions d'Azure pour configurer une ressource vocale. Les éléments importants dont vous aurez besoin pour votre code sont les suivants :

  • La clé d'abonnement (une longue chaîne de lettres et de chiffres)
  • L'emplacement ou la région (par exemple, westus)

Définissez-les comme variables d'environnement intitulées AZURE_SPEECH_SUBSCRIPTION_KEY et AZURE_SERVICE_REGION et voyons comment les utiliser dans du code.

Ajoutez une dépendance pour le SDK client Azure Speech à côté de l'emplacement où vous avez ajouté la dépendance Twilio dans build.gradle. Nous aurons besoin d'un analyseur JSON ultérieurement. Par conséquent, ajoutez Jackson ici également :

implementation group: 'com.microsoft.cognitiveservices.speech', name: 'client-sdk', version: "1.16.0", ext: "jar"
implementation 'com.fasterxml.jackson.core:jackson-core:2.12.3'

Créez un package intitulé Azure en regard de toutes vos classes, puis une classe intitulée AzureSpeechToTextService pour encapsuler la connexion à Azure :

public class AzureSpeechToTextService {

    private static final String SPEECH_SUBSCRIPTION_KEY = System.getenv("AZURE_SPEECH_SUBSCRIPTION_KEY");
    private static final String SERVICE_REGION = System.getenv("AZURE_SERVICE_REGION");

    private final PushAudioInputStream azurePusher;

    public AzureSpeechToTextService(Consumer<String> transcriptionHandler) {

        azurePusher = AudioInputStream.createPushStream(AudioStreamFormat.getWaveFormatPCM(8000L, (short) 16, (short) 1));

        SpeechRecognizer speechRecognizer = new SpeechRecognizer(
            SpeechConfig.fromSubscription(SPEECH_SUBSCRIPTION_KEY, SERVICE_REGION),
            AudioConfig.fromStreamInput(azurePusher));

        speechRecognizer.recognizing.addEventListener((o, speechRecognitionEventArgs) -> {
            SpeechRecognitionResult result = speechRecognitionEventArgs.getResult();
            transcriptionHandler.accept("recognizing: " + result.getText());
        });

        speechRecognizer.recognized.addEventListener((o, speechRecognitionEventArgs) -> {
            SpeechRecognitionResult result = speechRecognitionEventArgs.getResult();
            transcriptionHandler.accept("recognized: " + result.getText());
        });

        speechRecognizer.startContinuousRecognitionAsync();
    }

    public void accept(byte[] mulawData) {
        azurePusher.write(MulawToPcm.transcode(mulawData));
    }

    public void close() {
        System.out.println("Closing");
        azurePusher.close();
    }
}

[ce code avec les importations sur GitHub]

Puisqu'il s'agit d'une quantité importante de code, nous allons la décomposer :

  • Lignes 3 et 4 : lecture des variables d'environnement qui vous authentifient auprès d'Azure. Je les ai définis avec le plug-in EnvFile d'IntelliJ.

Dans le constructeur :

  • Ligne 10 : créez un élément PushAudioInputStream que nous pouvons utiliser pour envoyer des données audio binaires à Azure.
  • Lignes 12 à 14 : créez et initialisez la classe de client Azure principale : SpeechRecognizer.
  • Lignes 16 à 19 : ajoutez un rappel pour les reconnaissances partielles. Cela permet d'obtenir des transcriptions mot à mot en temps réel.
  • Lignes 21 à 24 : ajoutez un autre rappel, cette fois-ci pour obtenir des reconnaissances complètes. Il s'agit de phrases complètes avec une majuscule et une ponctuation correctes. Elles ont tendance à être plus précises que les reconnaissances partielles (nous verrons un exemple ci-dessous), mais elles sont fournies légèrement plus lentement.
  • Ligne 26 :speechRecognizer.startContinuousRecognitionAsync();- Ouvrez la connexion à Azure.

La méthode accept aux lignes 29-31 prend un byte[] contenant des données audio binaires de Twilio. Le codage utilisé par Twilio est appelé μ-law et est conçu pour une compression efficace de la voix enregistrée. Malheureusement, Azure n'accepte pas les données codées au format μ-law. Nous devons donc les transcoder dans un format accepté, à savoir PCM. Ces détails ne sont pas inclus dans ce tutoriel, mais vous pouvez télécharger la classe MulawToPcm utilisée à la ligne 30 ci-dessus depuis GitHub dans le package azure et l'utiliser directement.

Connecter le gestionnaire WebSocket à Azure

Le dernier morceau de code à ajouter est l'élément TwilioMediaStreamsHandler que vous avez créé précédemment. Les méthodes ne comportent que des espaces réservés pour le moment, mais elles doivent utiliser AzureSpeechToTextService. Remplacez le contenu de cette classe par :

public class TwilioMediaStreamsHandler extends AbstractWebSocketHandler {

    private final Map<WebSocketSession, AzureSpeechToTextService> sessions = new ConcurrentHashMap<>();

    private final ObjectMapper jsonMapper = new ObjectMapper();
    private final Base64.Decoder base64Decoder = Base64.getDecoder();;

    @Override
    public void afterConnectionEstablished(WebSocketSession session) {
        System.out.println("Connection Established");
        sessions.put(session, new AzureSpeechToTextService(System.out::println));
    }

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws JsonProcessingException {

        JsonNode messageNode = jsonMapper.readTree(message.getPayload());

        String base64EncodedAudio = messageNode.path("media").path("payload").asText();

        if (base64EncodedAudio.length() > 0){
            // not every message contains audio data
            byte[] data = base64Decoder.decode(base64EncodedAudio);
            sessions.get(session).accept(data);
        }

    }

    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
        System.out.println("Connection Closed");
        sessions.get(session).close();
        sessions.remove(session);
    }
}

[ce code avec les importations sur GitHub]

Ce code conserve un élément Map<WebSocketSession, AzureSpeechToTextService> afin que plusieurs appels puissent être transcrits en même temps sans que le son n'interfère d'un appel à un autre. Chaque AzureSpeechToTextService est initialisé sur la ligne 11. Le constructeur prend un élément Consumer<String> qui est utilisé pour gérer les transcriptions lorsqu'elles reviennent d'Azure. Ici, j'ai transmis System.out::println en tant que référence de méthode.

La méthode handleTextMessage sera appelée environ 50 fois par seconde, car de nouvelles données audio arrivent de Twilio en petits morceaux. Les messages sont au format JSON avec les données audio μ-law codées en base64 dans le JSON. Nous utilisons donc Jackson et java.util.Base64 pour extraire les données audio et les transmettre.

 Le code est enfin complet

Utiliser ce code avec un vrai numéro de téléphone

Connectez-vous à votre compte Twilio (utilisez ce lien si vous devez en créer un maintenant. Vous recevrez un crédit supplémentaire de 10 $ lorsque vous mettez à niveau votre compte). Achetez un numéro de téléphone et rendez-vous sur la page de configuration pour obtenir votre nouveau numéro. Vous souhaitez insérer une URL lorsqu'un appel entre (« a call comes in ») sous « Voice & Fax » (Synthèse vocal et télécopie). Mais quelle URL ? Actuellement, l'application n'est disponible que sur une URL localhost que Twilio ne peut atteindre. Vous disposez de deux options pour exposer votre application à l'Internet public :

  1. Exécutez ngrok directement
  2. Utilisez la CLI Twilio

Redémarrez l'application, soit en exécutant la méthode main de MediaStreamsAzureTranscriptionApplication, soit avec ./gradlew clean bootRun sur la ligne de commande. Dans tous les cas, le serveur écoute sur le port 8080.

Créer une URL publique à l'aide de ngrok

La commande suivante crée une URL publique pour votre serveur localhost:8080 :

ngrok http 8080

La sortie Ngrok contiendra une URL https de transfert comme https://<RANDOM LETTERS>. Il s'agit de l'URL que vous devez saisir lorsqu'un appel entre (« a call comes in ») dans votre console Twilio. N'oubliez pas d'ajouter le chemin d'accès de /twiml et d'enregistrer la configuration du numéro de téléphone.

Se connecter à l'aide de la CLI Twilio

La CLI Twilio peut être utilisé pour configurer ngrok et votre numéro de téléphone en une seule étape :

twilio phone-numbers:update <your phone number> --voice-url=http://localhost:8080/twiml

La CLI Twilio détecte l'URL localhost et configure ngrok pour vous. Génial, non ?

Appelez-moi ?

Une fois que l'application est en cours d'exécution et que ngrok ou la CLI Twilio vous a donné une URL publique configurée dans la console Twilio, il est temps de passer un appel téléphonique et de regarder la sortie sur votre console :

session de terminal montrant la transcription en temps réel de « Je parle et le son en direct est diffusé sur mon application, incroyable »

[voir la session complète sur asciinema]

Conclusion

Twilio Media Streams et Azure Cognitive Services Speech collaborent pour produire des transcriptions en temps réel de haute qualité. Vous pouvez étendre cette fonctionnalité pour transcrire chaque intervenant d'une conférence téléphonique séparément, l'associer à d'autres services cloud pour créer des résumés, rechercher des mots-clés lors d'un appel, construire des outils pour aider les agents d'appel ou aller là où votre imagination vous mène.  Si vous avez des questions à ce sujet ou sur tout autre élément que vous construisez avec Twilio, je serais heureux d'en savoir plus.

  •  @MaximumGilliard
  •  mgilliard@twilio.com

J'ai hâte de voir ce que vous allez construire !