ZOOMの日本語音声を無料で英語に翻訳した字幕をつける。

# ZOOMで日本語の音声を無料で英語に翻訳した字幕をつける。

音声の翻訳はAzureのSpeech translationの無料枠を使用します。

## 構成

```plantuml
@startuml
actor User2
node Client {
  actor User1
  component Application
  interface ミキサー
}
node Zoom
node Azure

Application <-- ミキサー : オーディオマイク
Application <--> Azure : Speech to transrate
ミキサー -- Zoom : スピーカー出力
Application --> Zoom : 字幕
User1 -- Zoom
User2 -- Zoom
@enduml
```

* ユーザがZoomに対していつものようにしゃべります。
* Zoomから声がスピーカーに出力されます。
* スピーカーからの出力をマイクとして音声を拾います。
* 音声をAzureの音声翻訳に送信し、翻訳したテキストとして受け取ります。
* 受け取った翻訳テキストをZoom APIを通して、字幕を表示します。


## ミキサーの設定

https://vb-audio.com/Cable/
上記からミキサーのドライバーをインストールします。
アプリケーションで出力した音を入力マイクとして音声を取得できます。
ZOOMで再生した音をミキサーに出力することで、その音を別アプリがマイクの入力として取得できます。

* 既存のマイクから音声を取得するので、ミキサーのマイクをWindowsの設定で既存に設定します。

## Azure

https://docs.microsoft.com/ja-jp/azure/cognitive-services/speech-service/get-started-speech-translation?tabs=script%2Cterminal&pivots=programming-language-java

### Azureの設定

* Azureの無料アカウントを取得してください。本人認証のためクレジットカートが必要です。無料アカウントのためお金はかかりません。
* 「Speech Services の作成」で音声サービスを使えるようにします。音声サービを作成します。
    * サブスクリプションキーを取得します。
    * リージョンを指定します。
* 無料枠は月5時間分と記述されていました。そのため、5時間を超えて使用したい場合は有料を契約してください。無料で5時間以上使うと停止する？いきなり課金は無いようです。
* 音声モデルの生成もできます。UTF8テキストを一行ごとに一文記述して、読み込ませることができます。その他の形式もあります。精度が低い場合は、モデルを生成し対応することができます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1371938/4378aafe-0c37-8ea7-181f-3ffdee1ee50f.png)


### コード

基本的にサンプルそのまま使用しています。
翻訳が確定したときにZOOMのキャプションに文字を送っています。
エラーなどで終了したときに、再度起動するようにコードを記述しています。

```
package speechtran;

import java.io.IOException;
import java.util.Map;
import java.util.Scanner;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.atomic.AtomicBoolean;

import com.microsoft.cognitiveservices.speech.*;
import com.microsoft.cognitiveservices.speech.translation.*;

/**
 * https://github.com/Azure-Samples/cognitive-services-speech-sdk/blob/master/samples/java/jre/console/src/com/microsoft/cognitiveservices/speech/samples/console/TranslationSamples.java
 */
public class Speech {

    static final String SPEECH__MODEL = "XXXXXXXXXXX-XXXXXXXX-9719-32ed32f643a7";
    static final String SPEECH__KEY = "XXXXXXXXXXXc119cfXXXXXXXa99c1e";
    static final String ZOOM = ""; // System.getenv("ZOOM"); //

    Caption caption = new Caption(ZOOM);

    AtomicBoolean end = new AtomicBoolean(false);

    public Speech() {

        caption.renewCount();
    }

    public void translate() {
        if (end.get()) {
            return;
        }

        new Thread(() -> {
            try {
                translationWithMicrophoneAsync();
            } catch (InterruptedException e) {
                e.printStackTrace();
            } catch (ExecutionException e) {
                e.printStackTrace();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }).start();
    }

    public void start() {
        translate();
        System.out.println("Say something...");
        System.out.println("Press any key to stop");
        new Scanner(System.in).nextLine();
        System.out.println("STOP!!");
        end.set(true);
        caption.end();
    }

    public void checkEnd(CountDownLatch countDownLatch) {
        if (end.get()) {
            countDownLatch.countDown();
        }
    }

    public void translationWithMicrophoneAsync() throws InterruptedException, ExecutionException, IOException {
        String speechSubscriptionKey = SPEECH__KEY;
        String speechServiceRegion = "japaneast";

        CountDownLatch countDownLatch = new CountDownLatch(1);

        try (SpeechTranslationConfig config = SpeechTranslationConfig.fromSubscription(speechSubscriptionKey,
                speechServiceRegion)) {

            String fromLanguage = "ja-JP";
            config.setSpeechRecognitionLanguage(fromLanguage);
            config.setEndpointId(SPEECH__MODEL);
            config.addTargetLanguage("en");

            String voice = "Speech Text to Trans";
            config.setVoiceName(voice);

            try (TranslationRecognizer recognizer = new TranslationRecognizer(config)) {
                // Subscribes to events.
                recognizer.recognizing.addEventListener((s, e) -> {
                    System.out.println("RECOGNIZING in : Text=" + e.getResult().getText());
                    checkEnd(countDownLatch);
                });

                recognizer.recognized.addEventListener((s, e) -> {
                    String t2 = e.getResult().getText();
                    String t1 = "";
                    if (e.getResult().getReason() == ResultReason.TranslatedSpeech) {
                        Map<String, String> map = e.getResult().getTranslations();
                        for (String element : map.keySet()) {
                            System.out.println("    TRANSLATED into : " + map.get(element));
                            t1 = map.get(element);
                        }
                        if (!"".equals(t2)) {
                            caption.send(t1, t2);
                        }
                    }
                    if (e.getResult().getReason() == ResultReason.RecognizedSpeech) {
                        System.out.println("RECOGNIZED: Text=" + e.getResult().getText());
                        System.out.println("    Speech not translated.");
                    } else if (e.getResult().getReason() == ResultReason.NoMatch) {
                        System.out.println("NOMATCH: Speech could not be recognized.");
                    }
                });

                recognizer.synthesizing.addEventListener((s, e) -> {
                    System.out.println(
                            "Synthesis result received. Size of audio data: " + e.getResult().getAudio().length);
                });

                recognizer.canceled.addEventListener((s, e) -> {
                    System.out.println("CANCELED:" + e.getSessionId() + " Reason=" + e.getReason());

                    if (e.getReason() == CancellationReason.Error) {
                        System.out.println("CANCELED: ErrorCode=" + e.getErrorCode());
                        System.out.println("CANCELED: ErrorDetails=" + e.getErrorDetails());
                    }
                    if ("ServiceError".equals(e.getErrorCode().name())) {
                        // retry
                        translate();
                        countDownLatch.countDown();
                    }
                });

                recognizer.sessionStarted.addEventListener((s, e) -> {
                    System.out.println("Session started:" + e.getSessionId());
                    checkEnd(countDownLatch);
                });

                recognizer.sessionStopped.addEventListener((s, e) -> {
                    System.out.println("Session stopped:" + e.getSessionId());
                    countDownLatch.countDown();
                });

                // Starts continuous recognition. Uses StopContinuousRecognitionAsync() to stop
                // recognition.
                recognizer.startContinuousRecognitionAsync().get();

                countDownLatch.await();
                System.out.println("end process");

                recognizer.stopContinuousRecognitionAsync().get();
            }
        }
    }

    public static void main(String[] args) {
        try {
            new Speech().start();
        } catch (Exception ex) {
            System.out.println("Unexpected exception: " + ex.getMessage());
            assert (false);
            System.exit(1);
        }
    }
}
```

## Zoomの設定

https://support.zoom.us/hc/ja/articles/115002212983-%E3%82%B5%E3%83%BC%E3%83%89%E3%83%91%E3%83%BC%E3%83%86%E3%82%A3%E3%81%AE%E5%AD%97%E5%B9%95%E3%82%B5%E3%83%BC%E3%83%93%E3%82%B9%E3%81%A8%E3%81%AE%E9%80%A3%E6%90%BA

上記に記述されているように、Zoomの画面から[字幕] をクリックします。[API トークンをコピー] をクリックします。クリップボードにトークンURLがコピーされます。
今回はJavaのプロパティから読み込ませるようにしています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1371938/f830edee-5654-ea13-e5c0-7b4a8cb9d357.png)


### Zoomに字幕を送る方法

HTTP 経由での字幕 URL の使用に記述されているように、取得したトークンに「seq」パラメータと「lang」パラメータを設定します。
また字幕に表示するテキストを、POSTで送ります。
「seq」は最初0からテキストを送るごとにカウントアップします。

### 現在の「seq」を取得する場合。

```
 https://wmcapi.zoom.us/closedcaption?id=200610693&ns=GZHkEA==&expire=86400&spparams=id%2Cns%2Cexpire&signature=nYtXJqRKCW
```
/closedcaption/seq [GET]に変更します。

```
 https://wmcapi.zoom.us/closedcaption/seq?id=200610693&ns=GZHkEA==&expire=86400&spparams=id%2Cns%2Cexpire&signature=nYtXJqRKCW
```
をGETで送ります。番号が帰ります。

### コード

java.net.HttpURLConnectionでHTTPリクエストを処理しています。そのためライブラリは追加で使用していません。
HTTPリスエストは、レスポンスまでに多少時間がかかるため、スレッド処理を行っています。
Executors.newFixedThreadPool(1)でマルチスレッドで処理を行っています。
sendメソッドで、字幕をZoomに送っています。
renewCountで現在のシーケンス番号を取得しています。
```setup.properties```にZOOM=でトークを記述してください。

```
package speechtran;

import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.ResourceBundle;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

import javax.net.ssl.HttpsURLConnection;

public class Caption {

    String url;
    int cnt = 0;
    ExecutorService exec = Executors.newFixedThreadPool(1);

    public Caption(String url) {
        if (url == null || "".equals(url)) {
            ResourceBundle rb = ResourceBundle.getBundle("setup");
            this.url = rb.getString("zoom");
        } else {
            this.url = url;
        }
        System.out.println(this.url);
    }

    public void send(String txt1, String txt2) {

        exec.submit(() -> {
            try {
                URL obj = new URL(url + "&lang=jp-JP&seq=" + cnt++);
                HttpsURLConnection con = (HttpsURLConnection) obj.openConnection();

                // add reuqest header
                con.setRequestMethod("POST");
                con.setRequestProperty("Content-Type", "text/plain");

                String urlParameters = txt1 + "\n" + txt2;

                // Send post request
                con.setDoOutput(true);
                OutputStream outputStream = con.getOutputStream();
                outputStream.write(urlParameters.getBytes("UTF-8"));
                outputStream.flush();
                outputStream.close();

                int responseCode = con.getResponseCode();

                // print result
                System.out.println(responseCode);
            } catch (Exception e) {
                e.printStackTrace();
            }

        });
    }

    public void renewCount() {
        try {

            URL obj = new URL(url.replace("/closedcaption?", "/closedcaption/seq?"));
            HttpURLConnection con = (HttpURLConnection) obj.openConnection();

            con.setRequestMethod("GET");

            con.getResponseCode();

            BufferedReader in = new BufferedReader(
                    new InputStreamReader(con.getInputStream()));
            String inputLine;
            StringBuffer response = new StringBuffer();

            while ((inputLine = in.readLine()) != null) {
                response.append(inputLine);
            }
            in.close();
            cnt = Integer.parseInt(response.toString()) + 1;
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    public void end() {
        try {
            exec.shutdown();
            exec.awaitTermination(10, TimeUnit.SECONDS);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    // public static void main(String[] args) {
    // Caption caption = new Caption(
    // "https://wmcc.zoom.us/closedcaption?id=1111111111111&ns=xxxxxxxxxx&expire=86400&sparams=id%2Cns%2Cexpire&signature=xxxxxxxxxxxxxxxxxxxxx");
    // caption.renewCount();
    // caption.send("aaaaaあああああああ1", "bbbbbb");
    // caption.send("aaaaa2", "bbbbbb");
    // caption.send("aaaaa3", "bbbbbb");
    // caption.send("aaaaa4", "bbbbbb");
    // caption.send("aaaaa5", "bbbbbb");
    // caption.send("aaaaa6", "bbbbbb");
    // caption.send("aaaaa7", "bbbbbb");
    // caption.send("aaaaa8", "bbbbbb");
    // caption.send("aaaaa9", "bbbbbb");
    // caption.end();
    // }

}

```

## 実際の実行

SpeechのMainを実行します。


### 喋った言葉が翻訳されてZoomに表示されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1371938/6de64b6c-39f6-20ce-ef70-781fdd298d2f.png)

発音した日本語と翻訳して英語が無事に表示されました。


## そのた

* 細かいところは説明を省略しています。
* AzureとZoomと音声をそれぞれ繋げばできます。