このツールを利用すると何ができる?
ビデオコンテンツ、Webinar、Web会議の音声データをテキストに変換し、画面上に字幕表示します。 翻訳機能も有しているので、英語のWebinarを視聴しながら日本語字幕をリアルタイムに表示することができます。
ツール自体の簡単な説明を書いているこちらの記事を読んでから、この記事を読んでいただくとイメージがつきやすいかと思います🙂
仕組みは?
UiPathには、複雑な処理を行うオリジナルのカスタム部品(カスタムアクティビティ)を作成する機能があります。
カスタムアクティビティにて、PCのスピーカー出力を取得し、 IBM Watson Speech to Text にて音声データをリアルタイムでテキストに変換し、その後 IBM Watson Language Translator にて翻訳を行い、結果を画面に字幕として表示しています。
ソースは以下Githubにて公開しています。C#でコードを書いて実装しています。
今回は、IBM Cloud Advent Calendar 2020の14日目の記事として書いていますので、IBM Cloud視点での実装裏話をしてみたいと思います!
使用しているのは、IBM Watson コグニティブサービス
リアルタイム翻訳&字幕アプリの作成に使用したIBM Cloudのサービスは以下の2つです。
1. Watson Speech to Text
音声データに含まれるテキストを文字起こししてくれる音声テキスト変換サービスです。略称はWatson STTです。私が勝手にそう呼んでるだけですがw
Watson STTには、大きく2通りの使い方があります。
1つは、 音声ファイルをアップロードして一括テキスト変換(バッチ処理)する使い方 です。同期HTTP、非同期HTTPインターフェースが用意されています。あらかじめ録音されたデータをぽいっとSTTに渡してあげればテキスト化してくれるので便利です!!
もう1つは リアルタイムに音声データを垂れ流しで送り続け、テキスト化されたデータを垂れ流しで受け取り続ける使い方 です。WebSocketインターフ ェースが用意されています。今回はWebinarなどでの音声データをリアルタイムで字幕として表示したいので、こちらを使用します。
2. Watson Language Translator
テキスト翻訳を行ってくれるサービスです。
英語から日本語に、日本語からドイツ語に、といった風に言語を指定して呼び出すことができます。
カスタム辞書を用意することで、翻訳の質向上やユーザー固有の情報を加味した翻訳ができるようになります。
※今回はカスタム辞書は使用していません。
実装に関するお話
普段仕事で触っていることもあり、UiPathをインターフェースに考えました。
UiPathには複雑な処理を行うオリジナルのカスタム部品(カスタムアクティビティ)を作成する機能があります。
UiPath画面でAPIKeyなどの設定情報を入力するだけで簡単にWatson STTやLanguage Translatorを利用できます。
Visual StudioとC#で開発
UiPathカスタムアクティビティ開発に使用するのはVisual Studio、C#の組み合わせがメジャーです。 UiPathはWindows Workflow Foundation(以下WF)というフレームワークをベースに作られているので、WFに従い実装した機能をUiPath上で動かすことができます。UiPathの中の人が作ってくれたActicity Creatorというカスタムアクティビティを簡単に作れるテンプレートがあるので、今回こちらを使用しています。めちゃ便利です。
Waston Speech to Textインターフェースの実装
ということで、C#(.NET)でWatson STTとのI/Fを実装しようと考えました。
こういうAPIサービスを利用した何かを作るときに確認する時、私はサービス仕様→API仕様→SDK仕様の順に調査します。
サービス仕様
実装したい機能の実現方法、できることできないことを確認します。認証周りについても確認しておきます。
Watson STTサービス仕様
API仕様
どのAPIを呼び出す必要があるのか、どういった呼び出し方(入出力パラメータや呼び出し方法)をするのかなどを確認します。
Watson STT API仕様
SDK仕様
全て手組みでコードを書く必要があるのか、あらかじめSDK(Software Development Kit)として提供されているのかを確認します。
Watson STT .NET SDK仕様
ということで、リアルタイム音声テキスト変換したいという要件に基づき調査し、調査した結果以下がわかりました。
- Web Socketインターフェースを使用する必要があること
- CurlやJava、NodeでのWeb Socketインターフェース実装サンプルはあったが.NETはないこと
- .NET用のSDKが提供されていることを確認したが、 Web Socketインターフェースは.NET SDKでは提供されていないこと を確認(悲報😥)
そうなんです。Watson STTでのWeb Socketは.NET SDKが対応しておらず、.NETコードでの実装サンプルが公式からはほぼ見つからなかったのです。。。 少し気が遠くなりました😧が、頑張ってみました。ということで作ったのがこんな感じ。
using myoshidan.IBM.Watson.STT.Models.DTO; | |
using Newtonsoft.Json; | |
using System; | |
using System.IO; | |
using System.Linq; | |
using System.Net.WebSockets; | |
using System.Text; | |
using System.Threading; | |
using System.Threading.Tasks; | |
namespace myoshidan.IBM.Watson.STT.Models | |
{ | |
public class IBMWatsonSpeechToTextWebsocketService | |
{ | |
public string Region { get; set; } | |
public string AccessToken { get; set; } | |
public string Model { get; set; } | |
public string Transcipt { get; private set; } | |
public int ResultIndex { get; set; } | |
private static string BaseUrlWithModel = "wss://{0}/speech-to-text/api/v1/recognize?access_token={1}&model={2}"; | |
private static string BaseUrl = "wss://{0}/speech-to-text/api/v1/recognize?access_token={1}"; | |
private static readonly ArraySegment<byte> OpenMessage = new ArraySegment<byte>(Encoding.UTF8.GetBytes("{\"action\": \"start\", \"content-type\": \"audio/wav\",\"inactivity_timeout\": -1, \"interim_results\": true}")); | |
private static readonly ArraySegment<byte> CloseMessage = new ArraySegment<byte>(Encoding.UTF8.GetBytes("{\"action\": \"stop\"}")); | |
private static ClientWebSocket ws = new ClientWebSocket(); | |
public IBMWatsonSpeechToTextWebsocketService(string region, string token,string model) | |
{ | |
this.Region = region; | |
this.AccessToken = token; | |
this.Model = model; | |
ws = new ClientWebSocket(); | |
} | |
public async Task StartConnection() | |
{ | |
Uri url; | |
if (string.IsNullOrEmpty(Model)) | |
{ | |
url = new Uri(string.Format(BaseUrl, Region, AccessToken)); | |
} | |
else | |
{ | |
url = new Uri(string.Format(BaseUrlWithModel, Region, AccessToken, Model)); | |
} | |
await ws.ConnectAsync(url, CancellationToken.None); | |
await ws.SendAsync(OpenMessage, WebSocketMessageType.Text, true, CancellationToken.None); | |
await HandleCallback(); | |
return; | |
} | |
public async Task CloseConnection() | |
{ | |
await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "Close", CancellationToken.None); | |
} | |
public async Task StartAudioFileSend(string filePath) | |
{ | |
await Task.WhenAll(SendAudioFileToWatson(filePath), HandleCallback()); | |
await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "Close", CancellationToken.None); | |
} | |
public async Task SendAudioToWatson(byte[] bytes) | |
{ | |
await ws.SendAsync(new ArraySegment<byte>(bytes), WebSocketMessageType.Binary, true, CancellationToken.None); | |
return; | |
} | |
public async Task HandleCallback() | |
{ | |
var buffer = new byte[1048576]; | |
while (true) | |
{ | |
var segment = new ArraySegment<byte>(buffer); | |
var result = await ws.ReceiveAsync(segment, CancellationToken.None); | |
if (result.MessageType == WebSocketMessageType.Close) return; | |
var count = result.Count; | |
while (!result.EndOfMessage) | |
{ | |
if (count >= buffer.Length) | |
{ | |
await ws.CloseAsync(WebSocketCloseStatus.InvalidPayloadData, "count >= buffer.Length!!!!!", CancellationToken.None); | |
return; | |
} | |
segment = new ArraySegment<byte>(buffer, count, buffer.Length - count); | |
result = await ws.ReceiveAsync(segment, CancellationToken.None); | |
count += result.Count; | |
} | |
var message = Encoding.UTF8.GetString(buffer, 0, count); | |
if (IsDelimeter(message))return; | |
var jsonObj = JsonConvert.DeserializeObject<StreamingRecognizeResponse>(message); | |
Transcipt = jsonObj.results.FirstOrDefault().alternatives.FirstOrDefault().transcript; | |
ResultIndex = jsonObj.result_index; | |
} | |
} | |
private async Task SendAudioFileToWatson(string filePath) | |
{ | |
using (var fileStream = File.Open(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) | |
{ | |
var bytes = new byte[1048576]; | |
while (fileStream.Read(bytes, 0, bytes.Length) > 0) | |
{ | |
await ws.SendAsync(new ArraySegment<byte>(bytes), WebSocketMessageType.Binary, true, CancellationToken.None); | |
} | |
await ws.SendAsync(CloseMessage, WebSocketMessageType.Text, true, CancellationToken.None); | |
} | |
} | |
private static bool IsDelimeter(string json) => JsonConvert.DeserializeObject<dynamic>(json).state == "listening"; | |
} | |
} |
まぁここはCurlやJavaの実装例なんかを見ながら作れたのだけど、難しかったのはPCのスピーカー出力をキャプチャーし、音声ストリームとしてWeb Socketに流し込むところ。
NAudioという音声処理系のライブラリを使うと、スピーカー出力をキャプチャーするのは簡単にできるんだけど、Watson君が受け入れてくれる形式に変換するのが難しかった。。。もう忘れたから説明もできないけどこんなかんじw
using NAudio.CoreAudioApi; | |
using NAudio.Wave; | |
using NAudio.Wave.SampleProviders; | |
using System; | |
using System.IO; | |
namespace myoshidan.IBM.Watson.STT.Models | |
{ | |
public class AudioMemoryRecorder : IDisposable | |
{ | |
public event EventHandler<AudioMemoryWaveInEventArgs> AudioMemoryWaveIn; | |
private WasapiLoopbackCapture _WaveIn; | |
private Stream _Stream; | |
private WaveFileWriter _WaveFileWriter; | |
private int lastPos; | |
public bool IsRecording | |
{ | |
get; | |
private set; | |
} | |
public AudioMemoryRecorder(MMDevice device) | |
{ | |
if (device == null) | |
throw new ArgumentNullException(nameof(device)); | |
this._WaveIn = new WasapiLoopbackCapture(device); | |
this._WaveIn.DataAvailable += this.WaveInOnDataAvailable; | |
this._WaveIn.RecordingStopped += this.WaveInOnRecordingStopped; | |
this._Stream = new MemoryStream(); | |
this._WaveFileWriter = new WaveFileWriter(this._Stream, new WaveFormat(16000, 2)); | |
this.lastPos = 0; | |
} | |
public void Start() | |
{ | |
this.IsRecording = true; | |
this._WaveIn.StartRecording(); | |
} | |
public void Stop() | |
{ | |
this.IsRecording = false; | |
this._WaveIn.StopRecording(); | |
} | |
private void WaveInOnRecordingStopped(object sender, StoppedEventArgs e) | |
{ | |
if (this._Stream != null) | |
{ | |
this._Stream.Close(); | |
this._Stream = null; | |
} | |
this.Dispose(); | |
} | |
private void WaveInOnDataAvailable(object sender, WaveInEventArgs e) | |
{ | |
if (e.BytesRecorded == 0) return; | |
var convertedBytes = convert16(e.Buffer, e.BytesRecorded, _WaveIn.WaveFormat); | |
this._WaveFileWriter.Write(convertedBytes, 0, convertedBytes.Length); | |
var seekPos = (int)_Stream.Position; | |
byte[] bytes = new byte[seekPos - lastPos]; | |
_Stream.Position = lastPos; | |
_Stream.Read(bytes, 0, bytes.Length); | |
_Stream.Position = seekPos; | |
this.lastPos = seekPos; | |
this.AudioMemoryWaveIn?.Invoke(this, new AudioMemoryWaveInEventArgs(bytes, bytes.Length)); | |
} | |
private byte[] convert16(byte[] input, int length, WaveFormat format) | |
{ | |
if (length == 0) | |
return new byte[0]; | |
using (var memStream = new MemoryStream(input, 0, length)) | |
{ | |
using (var inputStream = new RawSourceWaveStream(memStream, format)) | |
{ | |
var sampleStream = new WaveToSampleProvider(inputStream); | |
var resamplingProvider = new WdlResamplingSampleProvider(sampleStream, 16000); | |
var ieeeToPCM = new SampleToWaveProvider16(resamplingProvider); | |
return readStream(ieeeToPCM, length); | |
} | |
} | |
} | |
private byte[] readStream(IWaveProvider waveStream, int length) | |
{ | |
byte[] buffer = new byte[length]; | |
using (var stream = new MemoryStream()) | |
{ | |
int read; | |
while ((read = waveStream.Read(buffer, 0, length)) > 0) | |
{ | |
stream.Write(buffer, 0, read); | |
} | |
return stream.ToArray(); | |
} | |
} | |
public void Dispose() | |
{ | |
this._WaveIn.DataAvailable -= this.WaveInOnDataAvailable; | |
this._WaveIn.RecordingStopped -= this.WaveInOnRecordingStopped; | |
this._WaveIn?.Dispose(); | |
this._Stream?.Dispose(); | |
} | |
#endregion | |
} | |
} |
このQiita記事など参考にさせていただきました。ありがとうございました。
Waston Language Translatorインターフェースの実装
SDK提供していたので、めちゃ楽できました。このコード量の少なさ。これですよこれ。
using IBM.Cloud.SDK.Core.Authentication; | |
using IBM.Cloud.SDK.Core.Authentication.Iam; | |
using IBM.Watson.LanguageTranslator.v3; | |
using myoshidan.IBM.Watson.STT.Models.DTO; | |
using Newtonsoft.Json; | |
using System; | |
using System.Collections.Generic; | |
using System.Linq; | |
namespace myoshidan.IBM.Watson.STT.Models | |
{ | |
public class IBMWatsonLanguageTranslatorService | |
{ | |
public string APIKey { get; set; } | |
public string URL { get; set; } | |
public string ModelId { get; set; } | |
public DateTime LastUpdate { get; private set; } | |
private Authenticator _authenticator; | |
private LanguageTranslatorService _languageTranslatorService; | |
public IBMWatsonLanguageTranslatorService(string apikey, string uri) | |
{ | |
APIKey = apikey; | |
URL = uri; | |
_authenticator = new IamAuthenticator(apikey: this.APIKey); | |
_languageTranslatorService = new LanguageTranslatorService("2018-05-01", _authenticator); | |
_languageTranslatorService.SetServiceUrl(this.URL); | |
} | |
public string Translate(string text) | |
{ | |
var result = _languageTranslatorService.Translate(new List<string>() { text }, ModelId); | |
var responseObj = JsonConvert.DeserializeObject<LanguageTranslatorResponse>(result.Response); | |
LastUpdate = DateTime.Now; | |
return responseObj.translations.FirstOrDefault().translation ; | |
} | |
} | |
} |
ということで、無事に実装できたわけですが、翻訳の精度がやはりいまいちなので、いずれは音声データのサンプリング方法をいじったり、カスタム辞書登録なども受け付けれらるようにしたいなぁとは思っていますが、いずれはと言っているうちはおそらく寝かせることでしょう。w
本ツールの使い方はこちらの記事に書いていますので、良かったら是非遊んでみてください!
以上です!