TRTCを使って豊かな字幕の表現を持つ音声チャットシステムをつくる。

# はじめに
WebRTC関連の技術の幅を広げるべく、TencentのTRTC(Tencent Real-Time Communication)の勉強をしています。いろいろ勉強するうちに面白い機能があったので、これを活用するネタを投稿したいと思います。

# 今回作るもの
TRTCを勉強してみたところ、ウェブカメラの映像以外にも、HTMLCanvasElementに書いたグラフィックスも映像として流すことができる機能があることがわかりました(Watermarkingという機能)。今回は、この機能を用いて、音声チャットにおいて、音声の特長に合わせて字幕を付けるアプリを作ってみたいと思います。

下の動画のようなものを作ります。ちなみに、これは某お笑いコンテストの優勝者のネタです。お二人の声を分離して入力してみたものです。オリジナルの動画に笑い声が入っていたり、井口さんのしゃべりも少し早口なので、音声認識の精度が少々悪いですが、井口さんの突っ込み(悪口？)が赤い表示になってたりして、ライブ感が増しているような気がします。
https://user-images.githubusercontent.com/48346627/209464329-bbf85e2e-e2c3-4d13-80c9-d747f2c2b872.mp4

(権利上問題なので音声は入れていませんが、実際は音声も送信されます。)

# 動機
今回なぜこういうものを作ろうと思ったか。次の通りです。

コロナ禍の影響でビデオ会議を行う機会が増えました。そして最近では移動制限もほぼなくなり移動する機会も増えました。ビデオ会議であればどこにいても端末があれば参加できるということで、忙しいビジネスマンは、移動中でもビデオ会議に参加するということが増えたのではないでしょうか（うれしくない人も多いでしょうけど）。

私の経験上、こういった参加の仕方をする場合、電車の中で声を出しにくい状況などであったりするので、発言をするというより議論の成り行きを把握するために参加していることが多いかなぁと思っています。移動中はあまり音も出せないのでイヤホンで議論を聞くのが普通なのでしょうが、私はイヤホンを持ち歩くのが面倒だったり、充電がされていなかったりで困ることがあります。

最近では、youtubeなどでも動画に自動で字幕を付けてくれますし、他にもリアルタイムで字幕を付けてくれるアプリケーションやサービスも結構あります。こういったものを利用すれば、私のようなずぼらな人間でも、スマホが一台あれば会議に参加できそうです。ですが、やはり単純な字幕だけでは、発言者の熱量など、議論において大切な情報が落ちている感が否めないです。

そこで、今回は単純に音声を認識して字幕表示するだけではなく、その音声の性質によって字幕の表現を変えることで、より多くの情報を伝えるようにしたいな、というのが今回の開発の動機です。

# システム構成
本システムでは、入力された音声をGoogle speechを使ってリアルタイムで文字起こししています。また、音声の音量分析もリアルタイムで行います。この音量に応じて装飾を変えて(今回は、色、サイズ、Weightを変えている)、HTMLCanvasElementに字幕を描画します。そして、このHTMLCanvasElement から映像をCaptureStreamし、TRTCのWatermarking機能を用いて映像とオリジナルの音声を送信します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/365991/71e47ef5-50ca-7c01-980e-6f398da7f5c4.png)

# 実装
今回のアプリケーションでキモとなる字幕の生成部分は次の通りです。

★１で字幕用のキャンバスを生成しています。★２はこのキャンバスに字幕を書き込む処理の関数となります。★３のところで、各字幕テキストの音量に応じて、フォントのスタイルと色を変化させて書き込んでいます。★４でキャンバスからMediaStreamを取得して、LocalStreamを生成しています。

```ts
// 新規のLocalStreamの作成処理
//// Video用のトラック作成処理
const canvas = document.createElement("canvas") // ★１　字幕用のキャンバスを生成
canvas.width = 640
canvas.height = 480
const processId = new Date().getTime()
renderingProcessIdRef.current = processId

const ScrollSpeed = 3 / 100
const drawCanvas = () => { // ★２　字幕生成関数
    const currentTime = new Date().getTime()

    const ctx = canvas.getContext("2d")!
    ctx.fillStyle = `#40645c`
    ctx.fillRect(0, 0, canvas.width, canvas.height)

    // ★３　各テキストのボリュームに応じてフォントのスタイルと色を決定します。そしてキャンバスに書き込みます。
    textsRef.current.forEach(x => { 
        const elapsed = currentTime - x.time
        if (x.volume > 0.3) {
            ctx.fillStyle = `rgba(255,0,0,1)`
            ctx.font = "900 30px Chalk"
        } else if (x.volume > 0.2) {
            ctx.fillStyle = `rgba(255,200,200,1)`
            ctx.font = "700 25px Chalk"
        } else if (x.volume > 0.1) {
            ctx.fillStyle = `rgba(255,255,255,1)`
            ctx.font = "700 25px Chalk"
        } else if (x.volume > 0.07) {
            ctx.fillStyle = `rgba(255,255,255,1)`
            ctx.font = "500 20px Chalk"
        } else if (x.volume > 0.05) {
            ctx.fillStyle = `rgba(255,255,255,1)`
            ctx.font = "100 20px Chalk"
        } else {
            ctx.fillStyle = `rgba(255,255,255,1)`
            ctx.font = "100 18px Chalk"
        }

        const lineNum = Math.ceil((ctx.measureText(x.word).width) / (canvas.width * 0.8))
        const wordNum = Math.floor(x.word.length / lineNum)
        for (let i = 0; i < lineNum; i++) {
            // ctx.fillText(`${x.word}[${x.volume.toFixed(2)}]`, 10, canvas.height - (elapsed * ScrollSpeed))
            // const text = x.word.slice(i * wordNum, i * (wordNum + 1))
            const text = x.word.slice(i * wordNum, i * wordNum + wordNum)
            const h = canvas.height - 25 - (elapsed * ScrollSpeed) + 25 * i
            if (h > 0) {
                ctx.fillText(`${text}`, 10, h)
            }
        }


    })
    if (processId == renderingProcessIdRef.current) {
        // console.log("requestAnimationFrame?", processId, renderingProcessIdRef.current)
        requestAnimationFrame(drawCanvas)
    }
}
drawCanvas()


// ★４　キャンバスからMediaStreamを取得し、LocalStreamに渡します。
const canvasStream = canvas.captureStream(10);
// <略>
localStreamRef.current = TRTC.createStream({ userId: usernameRef.current, audioSource: audioMediaStream.getAudioTracks()[0], videoSource: canvasStream.getVideoTracks()[0] });
```

この部分がキモとなります。

今回は、その他にGoogle Speechを使ったリアルタイム書き起こしをしていたり、マイクの音量分析をしていたりします。これらはTRTCに直接かかわるものではありませんので、今回は説明しません。詳細は後述するリポジトリを参照いただければと思います。

# デモ
先ほどの漫才のは音声認識の精度がいまいちだったので、Voicevoxのずんだもんと春日部つむぎちゃんに「[利己主義者と友人との対話](https://www.aozora.gr.jp/cards/000153/files/43031_16331.html)」を読ませてみました。音声認識の精度は結構いいけど、今度は抑揚がないのであまり字幕表現のバリエーションが少な目かもしれませんね。実際使ってみると、結構表現が変わるので面白いですよ。実際に動くものは下記のリポジトリにおいてあるので是非使ってみてください。

https://user-images.githubusercontent.com/48346627/209466181-9e94386a-d342-453d-b5b6-3fe38a13d15a.mp4


# リポジトリ
https://github.com/w-okada/trtc-voice-visualization

# 使い方

## 前提 その１
TRTCを使用したウェブアプリを作成するには、TencentCloudのアカウントを作成する必要があります。[こちら](https://qiita.com/advent-calendar/2022/tencentcloud03)にリンクが張られている「アカウント解説手順」に従い、アカウントを作成しておいてください。
フリートライアルでテストで使用する場合は、クレジットカードの登録や免許証の登録はせずに使えるようです。

## 前提 その２
[こちらを参考](https://qiita.com/wok/items/a24ace540dc1404c4f45)にTRTCのアプリケーションIDとシークレットを生成しておいてください。

## 操作
リポジトリをクローンして、フォルダを移動します。
```
$ git clone https://github.com/w-okada/trtc-voice-visualization.git -b qiita-advent-2022
$ cd trtc-voice-visualization/
```

まず、src/const.tsにアプリケーションIDとシークレットを記載してください。


そして、必要なパッケージをインストールします。
```
$ npm install
```

音量分析用のworkletをビルドして、アプリケーションを起動します。

```
$ npm run build:worklet
$ npm run start
```


# さいごに
今回は、音量のみで字幕の表現を変化させていましたが、将来的にはAIを用いた感情分析を用いて表現を変化させることもできると思います。また、表現自体もフォントの色やサイズを変えるだけでしたが、さらにアニメーションを付けたり、インパクトのある装飾したりすることもできると思います。会議や各話者の状況をより深く捉え、より適切な表現をできるようにすることで、字幕であっても臨場感の高い会議体験が実現できるようになるかもしれません。

会議とは離れてしまいますが、これの拡張として将来的にはメタバースでワギャンランド[^1]的なものが作れるかも、とか考えると夢が広がります。

# 謝辞
VOICEVOX:ずんだもん 
VOICEVOX:春日部つむぎ 
テキスト：利己主義者と友人との対話 石川啄木

[^1]: 昔のゲーム。恐竜みたいなキャラが声を物質化して敵にぶつけて倒していく（[参考](https://ja.wikipedia.org/wiki/%E3%83%AF%E3%82%AE%E3%83%A3%E3%83%B3%E3%83%A9%E3%83%B3%E3%83%89)）。ドラえもんで言うとコエカタマリン。