Webページでブラウザの音声合成機能を使おう - Web Speech API Speech Synthesis

この記事は、[CPS Lab Advent Calendar 2018](https://adventar.org/calendars/3058)の5日目の記事です。

音声認識編 → https://qiita.com/hmmrjn/items/4b77a86030ed0071f548

# はじめに
みなさん、ウェブサイトで音声合成(読み上げ)、音声認識を使いたいとなった時、どうしますか?

- Google Cloud Speech API
- Microsoft Azure Text to Speech API
- IBM Watson Text to Speech API
- AWS Amazon Polly

などのがつがつとしたWebサービスが思いつくのではないでしょうか?

でも、登録しないといけないし、お金かかりそうですよね...
実はそれ、最近のブラウザであれば、無料でできるんです。

# Web Speech API とは
自分の周りでは意外と知っている人が少なかったので紹介します。

- Webページで、ブラウザの音声認識(マイクの音声を文章に変換)、音声合成(文章を読み上げる)機能を使うためのAPI。
- 2012年にWC3から[仕様](https://w3c.github.io/speech-api/speechapi.html)が策定された。(が、最近になってやっとブラウザが対応してきている。)
- Webサービスじゃないので、お金も、登録も、認証キーも一切いらない。
- 軽いJavaScriptの知識があれば誰でも使える。
- HTML/CSS/JSだけの静的ページでも動く。(ブラウザ上で動くので、バックエンドサーバは不要。)
- ブラウザのネイティブAPIなので、JavaScriptライブラリをインポートする必要もなし。
- ただ、音声合成はブラウザによって使える声が違って、音声認識は現時点では、Chromeしか対応していない...。

## Demo
一見は百聞に如かず。

※ iPhoneの場合はマナーモードを解除しないと音声は流れません。

- [音声合成 Demo](https://codepen.io/rodhamjun/pen/BGvrqr)
- [音声認識 Demo](https://www.google.com/intl/ja/chrome/demos/speech.html) ← ※ Chrome/Androidのみ

## ブラウザの対応状況

### 音声合成 (Speech Synthesis)
<img width="1248" alt="スクリーンショット 2018-12-02 17.59.19.png" src="https://qiita-image-store.s3.amazonaws.com/0/225997/afd058df-60d8-9adb-7ace-32ddd9034651.png" style="margin: 0px">
https://caniuse.com/#feat=speech-synthesis
オッ。Internet Explorer 以外はほとんどが対応してる。:clap::clap::clap:

### 音声認識 (Speech Recognition)
<img width="1251" alt="スクリーンショット 2018-12-02 18.02.37.png" src="https://qiita-image-store.s3.amazonaws.com/0/225997/3903ffd9-5587-f23c-f244-8eba84021470.png" style="margin: 0px">
https://caniuse.com/#feat=speech-recognition
ヒィー。Chromeしか対応してないし、部分対応だし、なんだこれ。:joy:
ただ、FirefoxとOperaは `In development` 中らしいので、 今後の動きに期待したい。[詳細](https://developer.microsoft.com/en-us/microsoft-edge/platform/status/webspeechapispeechrecognition/)


# 音声認識 (Speech Recognition)

記事が長くなったので、次週やります。:bow:
https://qiita.com/hmmrjn/items/4b77a86030ed0071f548

# 音声合成 (SpeechSynthesis)

文字列をブラウザに声で読んでもらう機能です。

## 対応確認

対応していないブラウザは少ないですが、ユーザのブラウザが対応しているかどうか調べたい場合はこちら。

![スクリーンショット 2018-12-05 4.55.51.png](https://qiita-image-store.s3.amazonaws.com/0/225997/40f04166-0be4-6150-e47b-e3ea056990e4.png)
https://jsfiddle.net/hmmrjn/o68vgqh0/1/

```html:index.html
<script>
  if ('speechSynthesis' in window) {
    alert("このブラウザは音声合成に対応しています。🎉")
  } else {
    alert("このブラウザは音声合成に対応していません。😭")
  }
</script>
```

## Hello World!

以下の2行で、ページをリロードするたび、ブラウザに「ハローワールド！」と喋らせることができます。
サーバ起動する必要なし。index.htmlファイルでも動きます。素敵ですね。

https://jsfiddle.net/hmmrjn/Loy85h9r/

```html:index.html
<script>
  // 発言を作成
  const uttr = new SpeechSynthesisUtterance("Hello World!")
  // 発言を再生 (発言キューに発言を追加)
  speechSynthesis.speak(uttr)
</script>
```

## 入力欄の文章を読み上げる

ボタンをクリックしたときに、入力欄の中身を読み上げたい場合は以下となります。
ちなみに連打すると、連打した分だけ、続けて読み上げます。

![スクリーンショット 2018-12-05 0.58.25.png](https://qiita-image-store.s3.amazonaws.com/0/225997/c3ddb2c9-a95f-ceeb-9672-3bbe1ceb0910.png)
https://jsfiddle.net/hmmrjn/wafd3sum/

```html:index.html
<textarea id="text">再生する内容</textarea>
<button id="speak-btn">再生</button>

<script>
  const text     = document.querySelector('#text')
  const speakBtn = document.querySelector('#speak-btn')

  speakBtn.addEventListener('click', function() {
    // 発言を作成
    const uttr = new SpeechSynthesisUtterance(text.value)
    // 発言を再生 (発言キューに発言を追加)
    speechSynthesis.speak(uttr)
  })
</script>
```

## 再生、停止、一時停止、再生再開

長文を読ませると、途中で黙らせるボタンが欲しくなります。
ここも直感的でとてもわかりやすいです。

**`speechSynthesis`** のメソッド

|メソッド|意味|
|:--|:--|
|`.speak(uttr)`|発言を再生 (発言キュー発言に追加)|
|`.cancel()`   |再生停止 (発言キューをクリアして止まる)|
|`.pause()`    |一時停止 (発言キューを保持して止まる)|
|`.resume()`   |再生再開 (一時停止を解除)|

![スクリーンショット 2018-12-05 1.12.43.png](https://qiita-image-store.s3.amazonaws.com/0/225997/609fc635-2013-7533-7b43-db23b2d1db68.png)
https://jsfiddle.net/hmmrjn/dmbhr6yv/

```html:index.html
<textarea id="text">
アドベントカレンダーは、クリスマスまでの日数を数えるためのカレンダー。
イエス・キリストの降誕を待ち望む24日間、窓を毎日ひとつずつ開けていき、
中には、イラストや物語の一編、甘いお菓子、小さな贈り物などが入っている。
</textarea>
<button id="speak-btn">再生</button>
<button id="cancel-btn">停止</button>
<button id="pause-btn">一時停止</button>
<button id="resume-btn">再開</button>

<script>
  const text      = document.querySelector('#text')
  const speakBtn  = document.querySelector('#speak-btn')
  const cancelBtn = document.querySelector('#cancel-btn')
  const pauseBtn  = document.querySelector('#pause-btn')
  const resumeBtn = document.querySelector('#resume-btn')

  speakBtn.addEventListener('click', function() {
    // 発言を作成
    const uttr = new SpeechSynthesisUtterance(text.value)
    // 発言を再生 (発言キュー発言に追加)
    speechSynthesis.speak(uttr)
  })
  cancelBtn.addEventListener('click', function() {
    // 再生停止 (発言キューをクリアして止まる)
    speechSynthesis.cancel()
  })
  pauseBtn.addEventListener('click', function() {
    // 一時停止 (発言キューを保持して止まる)
    speechSynthesis.pause()
  })
  resumeBtn.addEventListener('click', function() {
    // 再生再開 (一時停止を解除)
    speechSynthesis.resume()
  })
</script>
```

## 言語、速度、高さ、音量を指定

HALL 9000 っぽい声にしてみる。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/225997/a44e5ca2-584b-545a-f812-558467045492.png)


https://jsfiddle.net/hmmrjn/0wb48ctg/

```js
// 発言を作成
const uttr = new SpeechSynthesisUtterance()

// 文章 (コンストラクタの引数以外に、この方法でも指定できます)
uttr.text = "Hi, Dave."

// 言語 (日本語:ja-JP, アメリカ英語:en-US, イギリス英語:en-GB, 中国語:zh-CN, 韓国語:ko-KR)
uttr.lang = "en-US"

// 速度 0.1-10 初期値:1 (倍速なら2, 半分の倍速なら0.5)
uttr.rate = 0.5

// 高さ 0-2 初期値:1
uttr.pitch = 0.5

// 音量 0-1 初期値:1
uttr.volume = 0.75

// 再生 (発言キュー発言に追加)
speechSynthesis.speak(uttr)
```

## 声の種類の指定

同じ言語でも、様々な声の種類が存在するときがあります。
例えば、男性と女性の声だったり、Appleが提供する声とGoogleが提供する声だったりします。
ただ、この指定の仕方が少し複雑だったので、ここは丁寧にみていきましょう。

||コード|型|説明|
|:--|:--|:--|:--|
|①|`speechSynthesis.getVoices()`|SpeechSynthesisVoice[]|ブラウザで使える声の配列を得る。|
|②|`speechSynthesis.onvoiceschanged = e => {...}`|コールバック関数|Chromeは使える声を非同期で読み込むため、使える声が増えたときに着火するイベント(これ)が必要となる。|
|③|`uttr.voice = voice`|SpeechSynthesisVoice|使う声を指定する。|

流れとしては、
① `getVoices()` で、使える声の配列を得て、
② 使える声が増えたらまた `getVoices()` で、使える声の配列を得て、
③ その配列の中から使いたい声を `uttr.voice =` に渡す形となります。

ちなみに、声のオブジェクト (`SpeechSynthesisVoice`) の中身はこんな感じです。

```console
[object SpeechSynthesisVoice] {
  name: "Google 日本語",      // 声の名前 (Google　日本語, Alice, Kyokoとか。ブラウザによって使える声が違う。)
  lang: "ja-JP",             // 声の言語 (BCP47言語タグ：ja-JP, en-US, en-GB, zh-CN, ko-KR とか。)
  default: false,            // ブラウザの既定の声か。(自分のChromeの場合、Kyokoがデフォルトだった。)
  localService: false,       // true:ローカル(OS)の声 false:リモート(ネット上)の声
  voiceURI: "Google 日本語"   // WebサービスのURI　(のはずなんだが、Chromeではなぜか声の名前が入ってる。気にしない。)
}
```

たとえば、selectboxで声の種類を選べるようにしたい場合こうなります。

![スクリーンショット 2018-12-05 2.31.08.png](https://qiita-image-store.s3.amazonaws.com/0/225997/7cfe9bee-5814-39df-0593-031c77234723.png)
https://jsfiddle.net/hmmrjn/u1y4aL9v/

```html:index.html
<textarea id="text">Hello. Nice to meet you.</textarea>
<select id="voice-select"></select>
<button id="speak-btn">再生</button>

<script>
  const text        = document.querySelector('#text')
  const voiceSelect = document.querySelector('#voice-select')
  const speakBtn    = document.querySelector('#speak-btn')

  // selectタグの中身を声の名前が入ったoptionタグで埋める
  function appendVoices() {
    // ①　使える声の配列を取得
    // 配列の中身は SpeechSynthesisVoice オブジェクト
    const voices = speechSynthesis.getVoices()
    voiceSelect.innerHTML = ''
    voices.forEach(voice => { //　アロー関数 (ES6)
      // 日本語と英語以外の声は選択肢に追加しない。
      if(!voice.lang.match('ja|en-US')) return
      const option = document.createElement('option')
      option.value = voice.name
      option.text  = `${voice.name} (${voice.lang})` //　テンプレートリテラル (ES6)
      option.setAttribute('selected', voice.default)
      voiceSelect.appendChild(option)
    });
  }

  appendVoices()

  // ② 使える声が追加されたときに着火するイベントハンドラ。
  // Chrome は非同期に(一個ずつ)声を読み込むため必要。
  speechSynthesis.onvoiceschanged = e => {
    appendVoices()
  }

  speakBtn.addEventListener('click', function() {
    // 発言を作成
    const uttr = new SpeechSynthesisUtterance(text.value)
    // ③ 選択された声を指定
    uttr.voice = speechSynthesis
      .getVoices()
      .filter(voice => voice.name === voiceSelect.value)[0]
    // 発言を再生 (発言キュー発言に追加)
    speechSynthesis.speak(uttr)
  })
</script>
```

## 使える声

このリンクで今のブラウザでどんな声が使えるかみれます。
https://codepen.io/rodhamjun/full/jQJEWQ/
自分の環境(Google Chrome/macOS Mojave)では、色んな言語の声があって、合わせて65個の声が使えました。

比較のため、macOSとWindowsとChromeで使える声を下に貼っておきます。
※多いので、英語と日本語の声のみで。

### macOS
macOS 10.14 Mojave

|name |lang　|localService|default|
|:--|:--|:--|:--|
| Kyoko | ja-JP | true | true |
| Alex | en-US | true | false |
| Daniel | en-GB | true | false |
| Fiona | en | true | false |
| Fred | en-US | true | false |


### Windows
Windows 10 Home 1803

|name |lang　|localService|default|
|:--|:--|:--|:--|
|Microsoft Ayumi - Japanese (Japan)|ja-JP|true|true|
|Microsoft Haruka - Japanese (Japan)|ja-JP|true|false|
|Microsoft Ichiro - Japanese (Japan)|ja-JP|true|false|

### Chrome
Chrome 69 for Mac

OSが提供する声に加えて、

|name |lang　|localService|default|
|:--|:--|:--|:--|
| Google 日本語 | ja-JP | false | false | chrome |
| Google US English | en-US | false | false |
| Google UK English Female | en-GB | false | false |
| Google UK English Male | en-GB | false | false |


- Windows の Microsoft Edge では Windowsの声、
- macOS の Safari では macOS の声、
- Google Chrome では macOS + Google の声 / Windows + Google の声、
- Firefox と Opera では OS (macOS / Windows) の声のみが使えました。

# 細かいところ

## `SpeechSynthesis`


**プロパティ**

|名前|型|意味|
|:--|:--|:--|
|`paused`|boolean|一時停止中か|
|`pending`|boolean|発言キューにまだ再生されていない発言が残っているか|
|`speaking`|boolean|再生中か|

**イベントハンドラ**
`onvoiceschanged`
`getVoices()` で返されるオブジェクトに変化があった。

**メソッド**

|名前|戻り値|意味|
|:--|:--|:--|
|`cancel()`|void|停止して、発言キューを空に|
|`pause()`|void|一時停止して、発言キューを保持|
|`resume()`|void|一時停止から再生状態に復帰|
|`speak(uttr)`|void|発言キューに発言を追加<br>uttr: `SpeechSynthesisUtterance`|

## `SpeechSynthesisUtterance`



**コンストラクタ**
`SpeechSynthesisUtterance()`
`SpeechSynthesisUtterance("発言する文章")`

**プロパティ**

|名前|型|意味|
|:--|:--|:--|
|`lang`|String|言語。BCP 47言語タグ<br>(ja-JP,en-US,en-GB,zh-CN,ko-KR)|
|`pitch`|float|声の高さ。 `0`〜`2` 初期値: `1`|
|`rate`|float|速度。`0.1`〜`10` 初期値: `1` (`2`= 倍速)|
|`text`|String or [SSML](https://www.w3.org/TR/speech-synthesis/)|文章。|
|`voice`|SpeechSynthesisVoice|声の種類。|
|`volume`|float|音量。`0`〜`1` 初期値: `1`|

**イベントハンドラ**

|名前|着火時|
|:--|:--|
|onboundary|単語もしくは文の境界(?)に達した|
|onend|発言が終了した|
|onerror|エラーが発生した|
|onmark|[SSML](https://www.w3.org/TR/speech-synthesis/)の"mark"タグに達したとき
|onpause|途中で一時停止された|
|onresume|一時停止状態が解除された|
|onstart|再生開始した|

onendが来ない問題
https://qiita.com/XyYaE7gmJtMJDjh/items/38f4c28d2dbe5b7ef9e2


## `SpeechSynthesisVoice`

**プロパティ**

|名前|型|意味|
|:--|:--|:--|
|name|String|声の名前|
|lang|String|声の言語 (BCP47言語タグ)|
|default|boolean|ブラウザの既定の声か。|
|localService|boolean|ローカル(OS)の声か ↔️リモート(ネット上)の声|
|voiceURI|String|WebサービスのURI|

# 最後に

最初は、音声認識と音声合成両方を記事にしようと思っていたのですが、
想定以上に長く(TL;DR不可避に)なってしまったので、音声認識はまた来週投稿します。
お楽しみに。
待ちきれない方は、[「Web Speech API | JavaScript プログラミング解説 - so-zou.jp」](https://so-zou.jp/web-app/tech/programming/javascript/media/audio/web-speech-api/)
がとてもよくまとまっているのでおすすめです。:santa::ok_hand:


最後に、SafariとEdge様へ:
**純正が対応してくれないと困る**

# 参考文献
MDN Web Docs
https://developer.mozilla.org/en-US/docs/Web/API/Web_Speech_API

W3C Specification
https://w3c.github.io/speech-api/speechapi.html

# 宣伝
- [5分でターミナルの「MacBook:~ Taro$」を見やすくして絶頂する方法](https://qiita.com/hmmrjn/items/15d0fe15e5d03586ad46)
- [【Bash】ターミナルのプロンプトのカスタマイズ方法まとめ](https://qiita.com/hmmrjn/items/60d2a64c9e5bf7c0fe60)
- [【心折れずに】MacでM5stackをはじめる](https://qiita.com/hmmrjn/items/2b2da09eecffcbdbad85)
- [【Rails 5】(新) form_with と (旧) form_tag, form_for の違い](https://qiita.com/hmmrjn/items/24f3b8eade206ace17e2)
- [【Rails 5.2】 Active Storageの使い方](https://qiita.com/hmmrjn/items/7cc5e5348755c517458a)