Twilio媒体流可用于使用WebSockets将电话中的实时音频流传到你的服务器。与文字转语音系统相结合,它可以用来生成一个电话的实时转录。在这篇文章中,我将向你展示如何设置一个Java WebSocket服务器来管理Twilio媒体流音频数据,并使用Azure认知服务语音进行转录。
先决条件
要遵循本教程,你需要以下几点:
- Java 11或更高版本,我推荐SDKMAN!用于Java版本管理
- 一个Twilio账户和一个Azure账户
- Ngrok或Twilio CLI
如果你想进行下一步,你可以在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_KEY 和AZURE_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。这些细节没有包括在本教程中,但你可以 从GitHub的azure 包中下载 上面第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 上使用。你有两个选择,将你的应用程序暴露在公共互联网上:
- 直接运行ngrok
- 使用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,现在是时候打一个电话并观察控制台的输出。
总结
Twilio Media Streams和Azure Cognitive Services Speech一起工作,产生高质量的实时转录。你可以扩展这一功能,单独转录电话会议中的每个发言者,与其他云服务结合起来,创建摘要,搜索电话中的关键词,建立工具来帮助呼叫代理,或去你想象力所及的地方。 如果你对此有任何疑问,或者你用Twilio构建的其他东西,我很乐意听到。