話した言葉が敬語に変換されるビデオ通話アプリを作ってみた

# はじめに

* WebRTCを用いたビデオ・音声通話の機能を簡単に実装できるサービス「SkyWay」
* 話題のAIチャットサービス「ChatGPT」

これらのサービスを組み合わせたら何か面白いものが作れそう！！
ということで、話した言葉が敬語に変換されるビデオ通話アプリを作ってみました。

# 完成イメージ

スマホ（Android）とPC（Windows 11）でビデオ通話をしてみた映像です。
ブラウザは Google Chrome を使用しております。
両方の画面を録画するため、スマホの画面もPCに写っております。

映像ではスマホに向けて話した言葉が敬語に変換され、その音声がPCから出力されています。
※ 音声が出力されますので、ご注意ください。
※ PC側のカメラは隠しているので、真っ黒な映像になっています。

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">SkyWay + ChatGPT + Amazon Polly で話した言葉が敬語に変換されるビデオ通話アプリを作ってみた <a href="https://t.co/MQuA0ohZNh">pic.twitter.com/MQuA0ohZNh</a></p>&mdash; ふぁるや (@falya128) <a href="https://twitter.com/falya128/status/1677288832067342336?ref_src=twsrc%5Etfw">July 7, 2023</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

# 仕様／技術構成

ベースは SkyWay を用いて作成した音声・ビデオ通話アプリです。
ビデオはそのままで、音声のみ加工して送信しています。処理の流れは以下の通りです。

マイクから入力された音声を文字起こし（文字データを生成）する。
→ ChatGPT を用いて、話した言葉を敬語に変換する。
→ 敬語に変換した文字データから音声データを生成する。
→ 音声データを通話先に送信する。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339770/53db26e2-1ff4-7044-c6b3-aef9ed7727d4.png" width="700" />

用いた技術は以下の通りです。

* Vue.js v3.3

* Tailwind CSS

* SpeechRecognition（ブラウザで利用できるウェブ音声 API のインターフェース）
使用目的：音声データを文字データを生成する

* Amazon API Gateway + AWS Lambda（Node.js）
使用目的：REST API を作成する

* OpenAI API（ChatGPT）
使用目的：話した言葉を敬語に変換する

* Amazon Polly（ディープラーニング技術を使用したテキスト読み上げサービス）
使用目的：文字データから音声データを生成する

# 作成手順

## 1. 話した言葉を敬語に変換する API を作成

まずはバックエンド側の実装から行います。AWS Lambda から OpenAI API を呼び出して、話した言葉を敬語に変換する API を作成します。

ChatGPT への指示はシンプルに以下の2つです。
* 今から話す言葉を敬語に直してください。
* 変換した言葉だけを返してください。

作成した Lambda 関数のプログラムはこちらです。

```javascript
import { Configuration, OpenAIApi } from "openai";

export const handler = async (event) => {
    const configuration = new Configuration({
        apiKey: process.env.OPENAI_API_KEY,
    });
    const openai = new OpenAIApi(configuration);
    const completion = await openai.createChatCompletion({
        model: "gpt-3.5-turbo",
        messages: [
            {
                role: "system",
                content: `今から話す言葉を敬語に直してください。`,
            },
            {
                role: "system",
                content: `変換した言葉だけを返してください。`,
            },
            { role: "user", content: event.queryStringParameters.text },
        ],
    });
    return {
        statusCode: 200,
        headers: {
            "Access-Control-Allow-Headers": "Content-Type",
            "Access-Control-Allow-Origin": "*",
            "Access-Control-Allow-Methods": "OPTIONS,POST,",
        },
        body: JSON.stringify({
            convertedText: completion.data.choices[0].message.content,
        }),
    };
};
```

Lambda 関数の作成が終わったら、API Gateway で REST API を作成します。
詳しい説明は省略させて頂きますが、以下の設定を行いました。
* GET メソッドで Lambda 関数を実行
* 「Lambda プロキシ統合の使用」にチェックを入れる
* 「CORS の有効化」を実行する

## 2. 文字データから音声データを生成する API を作成

AWS Lambda から Amazon Polly を呼び出して、文字データから音声データを生成する API を作成します。
作成した Lambda 関数のプログラムはこちらです。

<details>
<summary>プログラム全体</summary>
<div>

```javascript
import { PollyClient, SynthesizeSpeechCommand } from "@aws-sdk/client-polly";
import { Buffer } from "buffer";

export const handler = async (event) => {
    // Polly に送信
    const command = new SynthesizeSpeechCommand({
        Engine: "neural",
        LanguageCode: "ja-JP",
        OutputFormat: "mp3",
        Text: event.queryStringParameters.text,
        VoiceId: "Takumi",
    });
    const client = new PollyClient({ region: "ap-northeast-1" });
    const response = await client.send(command);

    // 返却されたバイナリデータをBase64形式に変換
    const uint8Array = await response.AudioStream.transformToByteArray();
    const stringToEncode = String.fromCharCode(...uint8Array);
    const body = Buffer.from(stringToEncode, "binary").toString("base64");
    return {
        statusCode: 200,
        headers: {
            "Content-Type": "audio/mpeg",
            "Access-Control-Allow-Headers": "Content-Type",
            "Access-Control-Allow-Origin": "*",
            "Access-Control-Allow-Methods": "OPTIONS,GET",
        },
        body,
        isBase64Encoded: true,
    };
};
```
</div>

</details>

次に、プログラムの詳細について説明します。

### 文字データから音声データを生成
Amazon Polly を用いて文字データから音声データ（mp3形式）を生成しています。また、話す人物を選択できるので、今回は男性の "Takumi" を指定しています。

```javascript
    const command = new SynthesizeSpeechCommand({
        Engine: "neural",
        LanguageCode: "ja-JP",
        OutputFormat: "mp3",
        Text: event.queryStringParameters.text,
        VoiceId: "Takumi",
    });
    const client = new PollyClient({ region: "ap-northeast-1" });
    const response = await client.send(command);
```
### 音声データをBase64形式に変換
API のレスポンスを Base64 形式で返却するため、バイナリデータの変換処理を行っています。

```javascript
    const uint8Array = await response.AudioStream.transformToByteArray();
    const stringToEncode = String.fromCharCode(...uint8Array);
    const body = Buffer.from(stringToEncode, "binary").toString("base64");
```

Lambda 関数の作成が終わったら先程と同様に、API Gateway で REST API を作成します。
詳しい説明は省略させて頂きますが、以下の設定を行いました。
* GET メソッドで Lambda 関数を実行
* 「Lambda プロキシ統合の使用」にチェックを入れる
* 「CORS の有効化」を実行する
* バイナリメディアタイプに "audio/mpeg" を追加

## 3. ビデオ通話画面の作成

Vue.js + Tailwind CSS でビデオ通話画面を作成します。
外枠のレイアウト部分は省略して、ページ部分のプログラムのみを記載させて頂きます。
作成したプログラムはこちらです。


<details>
<summary>プログラム全体</summary>
<div>

```vuejs
<script setup>
import { onMounted, ref } from 'vue'
import axios from 'axios'
import {
  SkyWayContext,
  SkyWayChannel,
  SkyWayStreamFactory,
  LocalAudioStream
} from '@skyway-sdk/core'
import skyway from '@/utils/skyway'

const isJoined = ref(false)

// 相手が話したメッセージリスト
const messages = ref([])
const MAX_MESSAGE_LENGTH = 3
const setMesssage = (data) => {
  messages.value.push(data)
  if (messages.value.length > MAX_MESSAGE_LENGTH) {
    messages.value.shift()
  }
}

// 話した内容をサーバ側で指定の形式に変換
const convertSpeech = async (text) => {
  const response = await axios.get(import.meta.env.VITE_CONVERT_TEXT_URL, {
    params: { text }
  })
  return response.data.convertedText
}

// 指定された文字データから音声データを生成
const convertAudio = async (text) => {
  const response = await axios.get(import.meta.env.VITE_FETCH_AUDIO_URL, {
    params: { text }
  })
  const base64Str = response.data
  const raw = atob(base64Str)
  return Uint8Array.from(Array.prototype.map.call(raw, (x) => x.charCodeAt(0)))
}

// 文字起こし
let audioPublication
const startRecognition = async () => {
  window.SpeechRecognition = window.webkitSpeechRecognition || window.SpeechRecognition
  const recognition = new window.SpeechRecognition()
  recognition.lang = 'ja-JP'
  recognition.continuous = true
  recognition.onresult = async (event) => {
    // 話した内容を文字で取得
    const text = event.results[event.resultIndex][0].transcript
    if (text === '') return
    console.log({ text })

    // 文字データを指定の形式に変換して音声データを生成
    const convertedText = await convertSpeech(text)
    const uint8Array = await convertAudio(convertedText)

    const audioContext = new AudioContext()
    const audioBuffer = await audioContext.decodeAudioData(uint8Array.buffer)
    const source = audioContext.createBufferSource()
    const mediaStreamDestination = audioContext.createMediaStreamDestination()
    source.buffer = audioBuffer
    source.connect(mediaStreamDestination)
    const { stream } = mediaStreamDestination
    setTimeout(() => {
      source.start()
    }, 1000)

    // 新しい Stream を Publish する
    if (audioPublication) {
      await member.unpublish(audioPublication.id)
    }
    const tracks = stream.getAudioTracks()
    const localAudioStream = new LocalAudioStream(tracks[0])
    audioPublication = await member.publish(localAudioStream)

    // 相手に文字データとして送信
    data.write(convertedText)
  }
  recognition.onend = () => {
    // Android で音声認識が終了してしまうため、終了したら文字起こしを再開させる
    startRecognition()
  }
  recognition.start()
}

// SkyWay の Channel 作成
const video = await SkyWayStreamFactory.createCameraVideoStream()
const data = await SkyWayStreamFactory.createDataStream()
const tokenString = skyway.generateTokenString()
const context = await SkyWayContext.Create(tokenString)
const channel = await SkyWayChannel.FindOrCreate(context, {
  type: 'p2p',
  name: 'skyway-room-name'
})

let member
const join = async () => {
  // Channel に参加
  member = await channel.join()
  await member.publish(video)
  await member.publish(data)

  // 文字起こし開始
  await startRecognition()

  // 相手から Publish された Stream を Subscribe する
  const subscribeAndAttach = async (publication) => {
    if (publication.publisher.id === member.id) return

    const { stream } = await member.subscribe(publication.id)
    if (stream.contentType === 'data') {
      // 相手が話したメッセージをテキストで表示
      stream.onData.add((data) => {
        setMesssage(data)
      })
    } else {
      switch (stream.track.kind) {
        case 'video':
          // 相手の映像を表示
          stream.attach(document.getElementById('remote-video'))
          break
        case 'audio':
          // 相手の音声を再生
          stream.attach(document.getElementById('remote-audio'))
          break
        default:
          return
      }
    }
  }
  channel.publications.forEach(subscribeAndAttach)
  channel.onStreamPublished.add((e) => {
    subscribeAndAttach(e.publication)
  })
  isJoined.value = true
}

onMounted(async () => {
  // 自分の映像を表示
  const localVideo = document.getElementById('local-video')
  video.attach(localVideo)
  await localVideo.play()
})
</script>

<template>
  <div class="mt-10 text-center bg-white">
    <div class="flex items-center justify-center gap-x-6 flex-col md:flex-row">
      <div class="w-4/5 md:w-2/5">
        <video id="local-video" playsinline autoplay muted class="bg-black" />
      </div>
      <div class="mt-4 md:mt-0 w-4/5 md:w-2/5">
        <template v-if="isJoined">
          <video id="remote-video" playsinline autoplay muted class="bg-black" />
          <audio id="remote-audio" playsinline autoplay controls class="mt-4" />
        </template>
        <button
          v-else
          class="bg-blue-500 text-white font-bold py-2 px-4 rounded mt-10"
          @click="join()"
        >
          参加する
        </button>
      </div>
    </div>
    <div class="m-6">
      <div v-for="(message, index) in messages.slice().reverse()" :key="index" class="my-2">
        {{ message }}
      </div>
    </div>
  </div>
</template>
```
</div>
</details>

次に、プログラムの詳細について説明します。

### SkyWay のビデオ通話部分
まずはベースとなる SkyWay の JavaScript SDK を用いて作成した部分です。こちらのチュートリアルを参考に作成しています。

https://skyway.ntt.com/ja/docs/user-guide/javascript-sdk/quickstart/

以下のプログラムではチュートリアルの内容に加えて、DataStream を用いた通信を追加しております。DataStream とは、SkyWay で通信する際に用いられるメディアの1つで、任意のデータの送受信が可能です。

* VideoStream（ビデオデータの送受信に利用）
* AudioStream（音声データの送受信に利用）
* DataStream（任意のデータの送受信に利用）

今回は、敬語に変換した言葉を音声出力と併せて画面にも表示するため、文字データを送信する用途で利用しました。

```vuejs
<script>
// SkyWay の Channel 作成
const video = await SkyWayStreamFactory.createCameraVideoStream()
const data = await SkyWayStreamFactory.createDataStream()
const tokenString = skyway.generateTokenString()
const context = await SkyWayContext.Create(tokenString)
const channel = await SkyWayChannel.FindOrCreate(context, {
  type: 'p2p',
  name: 'skyway-room-name'
})

let member
const join = async () => {
  // Channel に参加
  member = await channel.join()
  await member.publish(video)
  await member.publish(data)

  // 文字起こし開始
  await startRecognition()

  // 相手から Publish された Stream を Subscribe する
  const subscribeAndAttach = async (publication) => {
    if (publication.publisher.id === member.id) return

    const { stream } = await member.subscribe(publication.id)
    if (stream.contentType === 'data') {
      // 相手が話したメッセージをテキストで表示
      stream.onData.add((data) => {
        setMesssage(data)
      })
    } else {
      switch (stream.track.kind) {
        case 'video':
          // 相手の映像を表示
          stream.attach(document.getElementById('remote-video'))
          break
        case 'audio':
          // 相手の音声を再生
          stream.attach(document.getElementById('remote-audio'))
          break
        default:
          return
      }
    }
  }
  channel.publications.forEach(subscribeAndAttach)
  channel.onStreamPublished.add((e) => {
    subscribeAndAttach(e.publication)
  })
  isJoined.value = true
}

onMounted(async () => {
  // 自分の映像を表示
  const localVideo = document.getElementById('local-video')
  video.attach(localVideo)
  await localVideo.play()
})
</script>
```

また、SkyWay ではトークンベースの認証・認可機能が提供されており、利用するためにはトークンを生成する必要があります。本プログラムでは、トークン生成の処理を別ファイルに記述して import しております。

```javascript:skyway.js
export default {
  generateTokenString: () => {
    const token = new SkyWayAuthToken({
      jti: uuidV4(),
      iat: nowInSec(),
      exp: nowInSec() + 60 * 60 * 24,
      scope: {
        app: {
          id: import.meta.env.VITE_SKYWAY_APP_ID,
          turn: true,
          actions: ['read'],
          channels: [
            {
              id: '*',
              name: '*',
              actions: ['write'],
              members: [
                {
                  id: '*',
                  name: '*',
                  actions: ['write'],
                  publication: {
                    actions: ['write']
                  },
                  subscription: {
                    actions: ['write']
                  }
                }
              ],
              sfuBots: [
                {
                  actions: ['write'],
                  forwardings: [
                    {
                      actions: ['write']
                    }
                  ]
                }
              ]
            }
          ]
        }
      }
    })
    return token.encode(import.meta.env.VITE_SKYWAY_SECRET_KEY)
  }
}
```

補足になりますが、シークレットキーを用いて第三者から不正利用される恐れがあるため、本来であればトークン生成の処理はフロントエンド側ではなく、バックエンド側で実装すべきです。バックエンド側で実装する場合の例につきましては、こちらの記事をご参照ください。

https://qiita.com/falya128/items/71d5a092a6119f8fad02

### REST API 呼び出し部分

次に API Gateway + Lambda で作成した API を呼び出す部分です。axios を用いて「話した言葉を敬語に変換する API」と「文字データから音声データを生成する API」を呼び出しています。

```vuejs
<script>
// 話した内容をサーバ側で指定の形式に変換
const convertSpeech = async (text) => {
  const response = await axios.get(import.meta.env.VITE_CONVERT_TEXT_URL, {
    params: { text }
  })
  return response.data.convertedText
}

// 指定された文字データから音声データを生成
const convertAudio = async (text) => {
  const response = await axios.get(import.meta.env.VITE_FETCH_AUDIO_URL, {
    params: { text }
  })
  const base64Str = response.data
  const raw = atob(base64Str)
  return Uint8Array.from(Array.prototype.map.call(raw, (x) => x.charCodeAt(0)))
}
</script>
```

### 文字起こし部分

最後に、SpeechRecognition を用いて文字起こしを行っている部分です。SpeechRecognition の文字起こしを開始した後、音声入力があれば onresult 関数が呼ばれる仕組みになっています。

onresult 関数の処理の流れは以下の通りです。

1. SpeechRecognition で音声入力から文字データを取得
2. 取得した文字データを敬語に変換
3. 敬語の文字データから音声データを生成
4. 音声データを SkyWay SDK で用いられる AudioStream に変換
参考にしたサイト：https://sublimer.hatenablog.com/entry/2019/09/18/110636
5. 公開済みの AudioStream を削除して、新たに生成した AudioStream を公開
6. DataStream を用いて敬語の文字データを送信

```vuejs
<script>
let audioPublication
const startRecognition = async () => {
  window.SpeechRecognition = window.webkitSpeechRecognition || window.SpeechRecognition
  const recognition = new window.SpeechRecognition()
  recognition.lang = 'ja-JP'
  recognition.continuous = true
  recognition.onresult = async (event) => {
    // 話した内容を文字で取得
    const text = event.results[event.resultIndex][0].transcript
    if (text === '') return
    console.log({ text })

    // 文字データを指定の形式に変換して音声データを生成
    const convertedText = await convertSpeech(text)
    const uint8Array = await convertAudio(convertedText)

    const audioContext = new AudioContext()
    const audioBuffer = await audioContext.decodeAudioData(uint8Array.buffer)
    const source = audioContext.createBufferSource()
    const mediaStreamDestination = audioContext.createMediaStreamDestination()
    source.buffer = audioBuffer
    source.connect(mediaStreamDestination)
    const { stream } = mediaStreamDestination
    setTimeout(() => {
      source.start()
    }, 1000)

    // 新しい Stream を Publish する
    if (audioPublication) {
      await member.unpublish(audioPublication.id)
    }
    const tracks = stream.getAudioTracks()
    const localAudioStream = new LocalAudioStream(tracks[0])
    audioPublication = await member.publish(localAudioStream)

    // 相手に文字データとして送信
    data.write(convertedText)
  }
  recognition.onend = () => {
    // Android で音声認識が終了してしまうため、終了したら文字起こしを再開させる
    startRecognition()
  }
  recognition.start()
}
</script>
```

上記の処理に setTimeout() で1秒待って音声を再生するという処理がありますが、これは通話先で音声の最初の部分が上手く再生できなかったため、やむなく実行しております・・・

# おわりに

WebRTCを用いたアプリ作成は初めてでしたが、なんとか想像通りの形に実装することができました。
実用的にビデオ通話アプリとして利用するには、以下のような課題が多く存在することがわかりました。

* 話してから相手に伝わるまでのタイムラグが大きい
* 連続して話すと、OpenAI API の 429 Too Many Requests エラーが発生する
* OpenAI API がタイミングによって非常に時間がかかるため、タイムアウトが発生する

ただ、ビデオ通話アプリの機能の1つとして、もし実現したら面白いなと思いました！

# GitHub

完成版の Vue プロジェクトはこちら

https://github.com/falya128/skyway-convert-speech

AWS のデプロイを簡単に行うため、CDK プロジェクトも作成しております。
必要に応じてご利用ください。

https://github.com/falya128/skyway-convert-speech-cdk