Google Cloud Speech-to-Text APIを使って口パク動画を作る

# 完成物

<iframe width="560" height="315" src="https://www.youtube.com/embed/bXNZqo6NKEo" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>

こういうムービーをブラウザ上で作ります。
![localhost_3000_ (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/21634/04f93fe6-6218-05af-5edd-a91e3618efe8.png)
テキストエリアに適当な文章を打ち込んで、生成した音声ファイルを読み込ませるとそれらしいアニメーションが再生されます。しかもムービーファイル(webm)もダウンロードできますよ、とそれだけのものではありますが、なかなかドンピシャなものがなかったので自作した次第です。

1. [Google Cloud Speech-to-Text API](https://cloud.google.com/speech-to-text?hl=ja) を使ってしゃべらせる。
1. 音声の抑揚にあわせて口パクムービーを作る。
1. ムービーとして書き出す。

以上3ステップです。

なお、できあがったのはこちらです。
https://github.com/kobaatsu/text-reader
nuxt+element-uiで動かしています。いろいろ遊んでいただければ。

# Google Cloud Speech-to-Text API

Google Cloud Speech-to-Text APIをプロジェクト内で有効にしてやり、認証ファイルなどを作ると準備完了です。無料枠もあるので、ちょっと遊ぶだけならじゅうぶんです。逆に言うと、皆様の手に取って遊べるところに置きたかったのですけれど、すぐに無料枠なんて突き抜けるだろうのでちょっと踏み切れなかったという……
塩辛い話はさておき、ほぼドキュメント通りに組み立てます。フロントからテキストデータをサーバーサイドに渡してやり、サーバーサイドではAPIを経由してmp3ファイルを受け取ります。

```/api/getSpeech.js
const projectId = process.env.PROJECT_ID // .env にprojectIDを記述
const keyFilename = path.join(__dirname, './auth/privatekey.json') // GCPで作った認証用のjsonを読み込み
const client = new textToSpeech.TextToSpeechClient({
  projectId,
  keyFilename,
})

const request = {
  input: { text }, // フロントからPOSTで渡されたテキストを渡す
  voice: { // 以下日本語・女声でしゃべらせる設定
    languageCode: 'ja-JP',
    name: 'ja-JP-Standard-B',
    ssmlGender: 'FEMALE',
  },
  audioConfig: {
    audioEncoding: 'MP3', // mp3で受け取ります
    speakingRate: 0.8, // 再生スピード。ゆっくりめにしました。割と早口なので
  },
}

// Performs the text-to-speech request
const [response] = await client.synthesizeSpeech(request)

const fileName = `speech/${dayjs().format('YYYYMMDDHHmmss')}.mp3`
const filePath = path.join(__dirname, '../static/' + fileName)
await writeFile(filePath, response.audioContent, 'binary')
res.json({ text, fileName })
```

受け取ったファイルを保存したらファイル名をフロントに返してやります。

# 口パクムービーを作る

こちらの記事をまるまる参考にしました。いや、参考といえば聞こえはいいですがほぼそのままです。ありがとうございます。
[Web Audio APIでリップシンク(もどき)を作ってみた話](https://qiita.com/iroha71/items/2867f61f459a333ffeb3)
ブラウザってこんなこともできるんだな〜〜。

```/pages/index.vue
async mounted() {
  // 画像object初期化
  this.cell.close = new Image()
  this.cell.close.src = '/face_normal.png' // 口閉じ(/static/face_normal.png)
  this.cell.half = new Image()
  this.cell.half.src = '/face_open_light.png' // 口ちょい開け
  this.cell.open = new Image()
  this.cell.open.src = '/face_open.png' // 口開け
  // canvas初期化
  this.canvasCtx = this.$refs.canvas.getContext('2d') // canvasのcontext
  this.canvasCtx.fillStyle = '#FFF'
  this.canvasCtx.fillRect(0, 0, 640, 360)
  this.canvasCtx.strokeStyle = '#ccc'
  this.canvasCtx.strokeRect(0, 0, 640, 360)
  // 画像読み込み後にcanvasに配置
  this.cell.close.onload = () => {
    this.drawImage(this.cell.close)
  }
},
methods: {
  // ...
  async onPlay() {
    // ...
    // 画像書き換え部分
    const samplingInterval = setInterval(() => {
      // totalSpectrum スペクトラム合計
      // prevSpectrum ひとつ前のスペクトラム合計
      if (totalSpectrum < 2000) {
        this.drawImage(this.cell.close) // 口閉じ画像に書き換え
      } else if (totalSpectrum > prevSpectrum) {
        this.drawImage(this.cell.open) // 口開け画像に書き換え
      } else if (prevSpectrum - totalSpectrum < 500) {
        this.drawImage(this.cell.half) // 口ちょい開け画像に書き換え
      } else {
        this.drawImage(this.cell.close) // 口閉じ画像に書き換え
      }
      // ...
    }, 1000 / fps) // FPSは録画とあわせるため別に記述
  },
  // ...
}
```
mount時に`canvas`に渡す画像を用意して`canvas`を初期化します。`drawImage()`はcanvasの絵を描きかえるために準備したmethodです。
上記の参考記事ではDOM操作で画像を書き換えていますが、最終的にムービーに変換するためにcanvasにしています。また動きを派手にするのに少し数値をいじりましたが、いずれにしたってほぼ参考記事まんまです。本当にありがとうございます。

# ムービーとして書き出す

今度はこちらの記事をまるまる参考にしています。いや、参考といえば聞こえはいいですが…ありがとうございます！
[Webブラウザで簡単な動画を生成する方法](https://recruit.gmo.jp/engineer/jisedai/blog/web-browser-movie/)
ブラウザってこんなこともできるんだな〜〜（二度目）。

```/pages/index.vue
methods: {
  // ...
  async onPlay() {
    // ...
    // 録音用のstream
    const mediaStreamDest = this.audioCtx.createMediaStreamDestination()
    const { stream: audioStream } = mediaStreamDest
    audioSrc.connect(mediaStreamDest)
    // スペクトラム解析用
    const analyser = new AnalyserNode(this.audioCtx)
    analyser.fftSize = 512
    // 接続
    audioSrc.connect(analyser).connect(this.audioCtx.destination)
    const fps = 12
    // canvasのstream
    const canvasStream = this.$refs.canvas.captureStream(fps)
    const streams = [canvasStream, audioStream]
    const mediaStream = new MediaStream()
　　 // trackを合成
    streams.forEach((stream) => {
      stream.getTracks().forEach((track) => mediaStream.addTrack(track))
    })
    // recorderの準備
    const recorder = new MediaRecorder(mediaStream, {
      mimeType: 'video/webm;codecs=vp9',
    })
    recorder.start() // 録画開始
    audioSrc.start() // 音声（＋アニメ）開始
    // ...
    audioSrc.onended = () => {
      recorder.stop() // 音声が止まったら録画も停止
      // ...
    }
    recorder.ondataavailable = (e) => {
      // recorderのデータが準備できたら
      // 以下のコンポーネントに渡すURLを作る
      // <a :href="movieUrl" download>DOWNLOAD MOVIE</a>
      const videoBlob = new Blob([e.data], { type: e.data.type })
      this.movieUrl = window.URL.createObjectURL(videoBlob)
    }
  },
  // ...
}
```
こちらも参考記事ほぼそのままです。ありがとうございます。

以上、組み合わせ組み合わせなしろものではありますが、無事目的は達成できました。
参考記事を書かれたお二人には何度でも感謝です。