使用Twilio媒体流、Azure认知服务和Java进行现场转录的详细指南

517 阅读10分钟

Twilio媒体流可用于使用WebSockets将电话中的实时音频流传到你的服务器。与文字转语音系统相结合,它可以用来生成一个电话的实时转录。在这篇文章中,我将向你展示如何设置一个Java WebSocket服务器来管理Twilio媒体流音频数据,并使用Azure认知服务语音进行转录。

先决条件

要遵循本教程,你需要以下几点:

如果你想进行下一步,你可以在GitHub的我的目录中找到完成的代码。

开始工作

为了快速启动和运行一个Java Web项目,我推荐使用Spring Initializr这个链接允许你为这个项目设置所有必要的配置。点击 "生成 "下载项目,然后解压并在你的IDE中打开该项目。

src/main/Java/com/example/twilio/mediastreamsazuretranscription ,将有一个单一的Java源文件,名为MediaStreamsAzureTranscriptionApplication.java 。你不需要修改这个文件,但它包含一个方法main ,你可以在以后使用它来执行代码。

接听电话并启动多媒体流媒体

首先,让我们创建代码,指示Twilio接听一个电话,背诵一条短消息,然后启动一个媒体流,我们将用它来转录。Twilio会将二进制音频数据流传到我们提供的URL,我们会将其发送到Azure进行转录。

首先,我们需要创建一个HTTP端点,将以下TwiML提供给/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>

在我们开始编码之前,让我们详细看看这个TwiML,看看发生了什么事:

  • 动词 [<Say>](https://www.twilio.com/docs/voice/twiml/say)(Say)使用文本到语音来朗读 "你好!"信息。
  • 然后启动(<Start>)一个媒体流([<Stream>](https://www.twilio.com/docs/voice/twiml/stream))到一个WebSocket URL(我们将在后面看到如何创建这个URL)。
  • 最后,暂停([<Pause>](https://www.twilio.com/docs/voice/twiml/pause))30秒的转录时间,然后挂断电话,结束通话和转录。

首先,将Twilio Helper库添加到项目中。这是一个Gradle项目。因此,在项目的根部有一个名为build.gradle 的文件,其中有一节dependencies ,你应该在其中加入.Net:

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

我们一直建议你使用最新版本的Twilio Helper库。最新的版本是8.12.0,但新版本经常发布。你可以随时在mvnreporistory.com查看最新版本

在与MediaStreamsAzureTranscriptionApplication 类相同的包中,创建一个名为TwimlRestController 的类,代码如下:

@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();
    }
}

你可以看到,WebSocket的URL目前是硬编码的。这对我们来说并不可行。

构建WebSocket URL

我们为处理HTTP请求而构建的应用程序也将处理TwilioWebSocket请求。WebSocket URLs与HTTP URLs非常相似,但我们不需要https://hostname/path ,而是需要wss://hostname/path

为了创建WebSocket URL,我们需要一个主机名和一个路径。对于路径,我们可以选择任何我们想要的东西(我们将使用/messages ),但主机名需要多做一点工作。我们可以硬编码,但这样一来,我们每次部署新东西时都要修改代码。一个更好的方法是检查对/twiml 的HTTP请求,看看那里使用的是什么主机名。我们将在主机标头中找到它。完整的HTTP请求看起来像这样:

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

我们可能会在代理或API网关后面部署,这可能需要潜入并改变主机头的值。Ngrok(我将在本教程的后面使用)就是这样一个代理。因此,我们还需要检查X-Original-Host 头,如果Host 已经被改变,它将被设置。一些代理机构将其称为X-Forwarded-Host ,甚至是其他东西,但这是同一件事。在这种情况下,一个HTTP请求可能看起来像这样:

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

对于这样的请求,URLwss:// 中的主机名应该是be136ff2eaca.ngrok.io 。现在我们知道了如何构建WebSocket URL,让我们看看它的代码。用以下内容取代TwimlRestController

    @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";
    }

快速检查...

通过你的IDE运行MediaStreamsAzureTranscriptionApplication 中的main 方法或在命令行上运行./gradlew clean bootRun ,检查一切工作是否符合预期。应用程序启动后,你可以使用Curl或任何其他HTTP工具向http://localhost:8080/twiml 。我对这种事情的选择工具是HTTPie。请注意,响应中的WebSocket URL将是wss://localhost:8080/messages ,因为你的客户在发出请求时将Host: Localhost:8080 作为头。这很理想。

管理WebSocket连接

在你的TwiML中拥有一个wss:// URL是很好的,但我们真的需要向它添加代码来处理Twilio WebSocket请求。否则,它只是一个404链接。在同一个包中,再次用这个内容创建一个名为TwilioMediaStreamsHandler 的类:

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");
    }
}

我们还需要一个配置类。称其为WebSocketConfig,在同一个包裹中:

@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {

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

接下来要实现的是将这个系统连接到Azure。

带着一丝丝蔚蓝的气息...

你可以从Azure语音转文字服务的文档中得到一个很好的概述。这是一项庞大的服务,有许多方式可以使用。在所有情况下,你将需要一个Azure账户。如果你还没有注册,请在此注册。这个项目很适合免费级别,每月5小时。按照Azure的指示来设置语音资源。你的代码需要的重要元素如下:

  • 订阅密钥(一长串的字母和数字)。
  • 地点或地区(例如,westus

将这些设置为环境变量,称为AZURE_SPEECH_SUBSCRIPTION_KEYAZURE_SERVICE_REGION ,让我们看看如何在代码中使用它们。

build.gradle 中添加Twilio依赖项的位置旁边添加Azure Speech client SDK的依赖项。我们以后将需要一个JSON解析器。因此,在这里也要加上杰克逊

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'

在你所有的类旁边创建一个名为Azure 的包,然后创建一个名为AzureSpeechToTextService 的类来封装与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();
    }
}

由于这是一个相当大的代码量,我们将把它分解:

  • 第3行和第4行:读取环境变量,以验证你对Azure的身份。我用IntelliJ的EnvFile插件来设置这些。

在构造函数中:

  • 第10行:创建一个元素PushAudioInputStream ,我们可以用它来发送二进制音频数据到Azure。
  • 第12至14行:创建并初始化主Azure客户端类:SpeechRecognizer
  • 第16-19行:为部分识别添加一个回调。这可以实现实时的逐字抄写。
  • 第21行至第24行:添加另一个回调,这次是为了完全识别。这些是完整的句子,有正确的大小写和标点符号。它们往往比部分识别更准确(我们将在下面看到一个例子),但它们的传递速度稍慢。
  • 第26行:speechRecognizer.startContinuousRecognitionAsync();- 打开与Azure的连接。

第29-31行的accept 方法从Twilio获取一个包含二进制音频数据的byte[] 。Twilio使用的编码被称为μ-law,是为有效压缩录制的语音而设计的。不幸的是,Azure不接受以μ-law格式编码的数据。所以我们必须把它转码成一种公认的格式,即PCM。这些细节没有包括在本教程中,但你可以 从GitHubazure 包中下载 上面第30行中使用的 MulawToPcm,并直接使用它。

将WebSocket管理器连接到Azure

要添加的最后一段代码是你先前创建的TwilioMediaStreamsHandler 元素。这些方法目前都是占位符,但它们应该使用AzureSpeechToTextService 。将该类的内容替换为:

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);
    }
}

这个代码保留了一个Map<WebSocketSession, AzureSpeechToTextService> 的元素,这样就可以同时转录多个电话,而不会有声音从一个电话干扰到另一个。每个AzureSpeechToTextService ,在第11行被初始化。构造函数接收一个元素Consumer<String> ,用于处理从Azure返回时的成绩单。在这里,我把System.out::println 作为一个方法的引用。

handleTextMessage 方法每秒将被调用约50次,因为新的音频数据以小块形式从Twilio到达。这些信息是JSON格式,音频数据μ-law在JSON中以base64编码。因此,我们使用杰克逊java.util.Base64 ,提取音频数据并传输。

代码终于完成了

用真实的电话号码使用这个代码

登录到你的Twilio账户(如果你现在需要创建一个账户,请使用此链接。当你升级你的账户时,你将收到一个额外的10美元信用额度)。购买一个电话号码,然后去设置页面获得你的新号码。你想在 "语音和传真 "下,当有电话打进来时插入一个URL。但哪个网址?目前,该应用程序只能在Twilio无法到达的URLlocalhost 上使用。你有两个选择,将你的应用程序暴露在公共互联网上:

  1. 直接运行ngrok
  2. 使用Twilio CLI

重新启动应用程序,可以通过运行MediaStreamsAzureTranscriptionApplication 中的main 方法或在命令行中使用./gradlew clean bootRun 。在这两种情况下,服务器都在监听8080端口。

使用ngrok创建一个公共URL

以下命令为你的服务器创建了一个公共URLlocalhost:8080

ngrok http 8080

Ngrok的输出将包含一个转发的https URL,如https://<RANDOM LETTERS> 。当有电话打进你的Twilio控制台时,这是你应该输入的URL。不要忘记添加路径到/twiml ,并保存电话号码的配置。

使用Twilio CLI进行连接

Twilio CLI可以用来在一个步骤中配置ngrok和你的电话号码:

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

Twilio CLI会检测URLlocalhost ,并为你配置ngrok。很好,不是吗?

给我打电话?

一旦应用程序运行,ngrok或Twilio CLI给你提供了一个在Twilio控制台配置的公共URL,现在是时候打一个电话并观察控制台的输出。

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

总结

Twilio Media Streams和Azure Cognitive Services Speech一起工作,产生高质量的实时转录。你可以扩展这一功能,单独转录电话会议中的每个发言者,与其他云服务结合起来,创建摘要,搜索电话中的关键词,建立工具来帮助呼叫代理,或去你想象力所及的地方。 如果你对此有任何疑问,或者你用Twilio构建的其他东西,我很乐意听到。