【Python】なろう小説を ずんだもん に読み聞かせしてもらおう

# はじめに
皆さんも、**なろう小説**が面白すぎて、ついつい読み過ぎて夜更かししてしまうことありますよね？
この夜更かしは、なろう小説の面白さもありますが、携帯から発生する**ブルーライト**が原因の１つであるとも言えます。
そのため、**ブルーライトを浴びずに、なろう小説を読む**ことができれば、程よいところで寝落ちができて、**毎日健康快眠生活**を送ることができると私は考えました。

本記事では、少し前から流行っている**ずんだもん**に、なろう小説を読み聞かせしてもらうことで、ブルーライトレスな毎日健康快眠生活を実現する方法について解説いたします。

::: note info
**なろう小説**
「小説家になろう」という小説投稿サイトに掲載された作品の総称
狭義では異世界転生系の小説を指す。
代表作として「転生したらスライムだった件」や「異世界はスマートフォンとともに。」が挙げられる。
:::

::: note info
**ずんだもん**
東北ずん子が所持するずんだアローに変身するずんだ餅の妖精。
VOICEVOX（無料の音声合成ソフト）で利用可能なボイスの1つ。
:::

# やりたいこと
快眠のために実装したいことは以下の3つです。
* 任意のなろう小説をダウンロードする。
* ダウンロードしたなろう小説からVOICEVOXを用いて音声を作成する。
* 音声を再生する。

加えて、小説の全ての内容について音声合成を行った後に、音声の再生をすると合成の間に寝落ちて本末転倒となることが想定されるため、音声の合成と再生は並列して行うこととします。

```mermaid
graph TD;
    なろう小説スクレイピング-->音声合成;
    音声合成-->音声再生;
    音声再生-->音声合成;
```

# 実装
## なろう小説のスクレイピング
**BeautifulSoup4**と**requests**を用いたスクレイピングでなろう小説をテキストファイルとして取得しました。
requestsでGETメソッドを使用し、ページ情報を得ましたが、その際に、requestsのデフォルトのUser-Agent（python-requests/2.26.0）ではアクセス権を得られなかったため（403 Forbidden）、[このサイト](http://httpbin.org/user-agent)で自身のUser-Agentを確認しそちらを利用しました。
次に、取得したHTMLからBeautifulSoup4を用いて必要な部分を取り出しました。
小説タイトルは`<title>`より取得、各話内容は`<div class="index_box">`のそれぞれ`<a href>`からリンク先に飛び、`<div class="novel_view">`の`<p>`から取得、各話タイトルもリンク先の`<p class="novel_subtitle">`から取得しました。
そして、以下のような形式でtextファイルに保存しました。

::: note info
**勇者パーティーを追放されたビーストテイマー、最強種の猫耳少女と出会う.txt**
勇者パーティーを追放されたビーストテイマー、最強種の猫耳少女と出会う
１話　ビーストテイマー、クビを宣告される
「キミはクビだ」

　それは、魔王軍の四天王の一人、『大地のギガブランド』を倒した後の出来事だった。

　街に戻った後、宿に泊まり……
　食事の後に、勇者アリオスの部屋に呼ばれた。
:::

<details><summary>コードはこちら</summary><div>

```Python
from bs4 import BeautifulSoup
import requests

# なろう小説URL
target_url = 'https://ncode.syosetu.com/n8810eu/'

# User-Agentを変更
headers = {
      'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/97.0.4692.71 Safari/537.36'
}

# ページ情報取得
r = requests.get(target_url,headers=headers)
soup = BeautifulSoup(r.text,'html.parser')

# 小説タイトル
title = soup.title.text

# 話数取得
index_num = len(soup.find('div', class_ = 'index_box').find_all('a'))

# 保存先テキストファイル
save_path = f'{title}.txt'

f = open(save_path, 'w', encoding='utf-8')
f.write(title + '\n')

# 各話のスクレイピング
for i in range(1, index_num+1):
    # urlを取得
    target_url_ = target_url + f'{i}/'
    
    # ページ情報取得
    r = requests.get(target_url_,headers=headers)
    soup = BeautifulSoup(r.text,'html.parser')

    # 各話タイトル
    subtitle = soup.find('p', class_ = 'novel_subtitle')
    f.write(subtitle.text + '\n')

    # 本文
    honbun = soup.find('div', class_='novel_view')
    for i in honbun.find_all('p'):
        f.write(i.text +'\n')

f.close()
```

</div></details>

## VOICEVOXによる音声合成
ずんだもん に読み聞かせをしてもらうために、VOICEVOXを用いました。VOICEVOXは無料のテキスト読み上げソフトであり、ずんだもん以外にも多くのキャラクターを使用することができます。
今回は[ホームページ](https://voicevox.hiroshiba.jp/)からダウンロードした製品版VOICEVOX ver0.14.3を用いて実装しました。
勿論GUIでの利用も可能ですが、今回の音声の合成は`run.exe`を実行し、そのHTTPサーバーにリクエストを送信することで行いました。また、起動中に`http://localhost:50021/docs`にアクセスすることでドキュメントを確認することができます。特に使用可能は話者情報は`/speakers`から以下のような形式で取得可能です（多種多様な声があり、ささやき声もあります。）。


<details><summary>speaker一覧</summary><div>

```json

[
  {
    "supported_features": {
      "permitted_synthesis_morphing": "SELF_ONLY"
    },
    "name": "四国めたん",
    "speaker_uuid": "7ffcb7ce-00ec-4bdc-82cd-45a8889e43ff",
    "styles": [
      {
        "name": "ノーマル",
        "id": 2
      },
      {
        "name": "あまあま",
        "id": 0
      },
      {
        "name": "ツンツン",
        "id": 6
      },
      {
        "name": "セクシー",
        "id": 4
      },
      {
        "name": "ささやき",
        "id": 36
      },
      {
        "name": "ヒソヒソ",
        "id": 37
      }
    ],
    "version": "0.14.1"
  },
  {
    "supported_features": {
      "permitted_synthesis_morphing": "SELF_ONLY"
    },
    "name": "ずんだもん",
    "speaker_uuid": "388f246b-8c41-4ac1-8e2d-5d79f3ff56d9",
    "styles": [
      {
        "name": "ノーマル",
        "id": 3
      },
      {
        "name": "あまあま",
        "id": 1
      },
      {
        "name": "ツンツン",
        "id": 7
      },
      {
        "name": "セクシー",
        "id": 5
      },
      {
        "name": "ささやき",
        "id": 22
      },
      {
        "name": "ヒソヒソ",
        "id": 38
      }
    ],
    "version": "0.14.1"
  },
  {
    "supported_features": {
      "permitted_synthesis_morphing": "ALL"
    },
    "name": "春日部つむぎ",
    "speaker_uuid": "35b2c544-660e-401e-b503-0e14c635303a",
    "styles": [
      {
        "name": "ノーマル",
        "id": 8
      }
    ],
    "version": "0.14.1"
  },
  {
    "supported_features": {
      "permitted_synthesis_morphing": "ALL"
    },
    "name": "雨晴はう",
    "speaker_uuid": "3474ee95-c274-47f9-aa1a-8322163d96f1",
    "styles": [
      {
        "name": "ノーマル",
        "id": 10
      }
    ],
    "version": "0.14.1"
  },
  {
    "supported_features": {
      "permitted_synthesis_morphing": "ALL"
    },
    "name": "波音リツ",
    "speaker_uuid": "b1a81618-b27b-40d2-b0ea-27a9ad408c4b",
    "styles": [
      {
        "name": "ノーマル",
        "id": 9
      }
    ],
    "version": "0.14.1"
  },
  {
    "supported_features": {
      "permitted_synthesis_morphing": "ALL"
    },
    "name": "玄野武宏",
    "speaker_uuid": "c30dc15a-0992-4f8d-8bb8-ad3b314e6a6f",
    "styles": [
      {
        "name": "ノーマル",
        "id": 11
      },
      {
        "name": "喜び",
        "id": 39
      },
      {
        "name": "ツンギレ",
        "id": 40
      },
      {
        "name": "悲しみ",
        "id": 41
      }
    ],
    "version": "0.14.1"
  },
  {
    "supported_features": {
      "permitted_synthesis_morphing": "ALL"
    },
    "name": "白上虎太郎",
    "speaker_uuid": "e5020595-5c5d-4e87-b849-270a518d0dcf",
    "styles": [
      {
        "name": "ふつう",
        "id": 12
      },
      {
        "name": "わーい",
        "id": 32
      },
      {
        "name": "びくびく",
        "id": 33
      },
      {
        "name": "おこ",
        "id": 34
      },
      {
        "name": "びえーん",
        "id": 35
      }
    ],
    "version": "0.14.1"
  },
  {
    "supported_features": {
      "permitted_synthesis_morphing": "ALL"
    },
    "name": "青山龍星",
    "speaker_uuid": "4f51116a-d9ee-4516-925d-21f183e2afad",
    "styles": [
      {
        "name": "ノーマル",
        "id": 13
      }
    ],
    "version": "0.14.1"
  },
  {
    "supported_features": {
      "permitted_synthesis_morphing": "ALL"
    },
    "name": "冥鳴ひまり",
    "speaker_uuid": "8eaad775-3119-417e-8cf4-2a10bfd592c8",
    "styles": [
      {
        "name": "ノーマル",
        "id": 14
      }
    ],
    "version": "0.14.1"
  },
  {
    "supported_features": {
      "permitted_synthesis_morphing": "SELF_ONLY"
    },
    "name": "九州そら",
    "speaker_uuid": "481fb609-6446-4870-9f46-90c4dd623403",
    "styles": [
      {
        "name": "ノーマル",
        "id": 16
      },
      {
        "name": "あまあま",
        "id": 15
      },
      {
        "name": "ツンツン",
        "id": 18
      },
      {
        "name": "セクシー",
        "id": 17
      },
      {
        "name": "ささやき",
        "id": 19
      }
    ],
    "version": "0.14.1"
  },
  {
    "supported_features": {
      "permitted_synthesis_morphing": "SELF_ONLY"
    },
    "name": "もち子さん",
    "speaker_uuid": "9f3ee141-26ad-437e-97bd-d22298d02ad2",
    "styles": [
      {
        "name": "ノーマル",
        "id": 20
      }
    ],
    "version": "0.14.1"
  },
  {
    "supported_features": {
      "permitted_synthesis_morphing": "ALL"
    },
    "name": "剣崎雌雄",
    "speaker_uuid": "1a17ca16-7ee5-4ea5-b191-2f02ace24d21",
    "styles": [
      {
        "name": "ノーマル",
        "id": 21
      }
    ],
    "version": "0.14.1"
  },
  {
    "supported_features": {
      "permitted_synthesis_morphing": "ALL"
    },
    "name": "WhiteCUL",
    "speaker_uuid": "67d5d8da-acd7-4207-bb10-b5542d3a663b",
    "styles": [
      {
        "name": "ノーマル",
        "id": 23
      },
      {
        "name": "たのしい",
        "id": 24
      },
      {
        "name": "かなしい",
        "id": 25
      },
      {
        "name": "びえーん",
        "id": 26
      }
    ],
    "version": "0.14.1"
  },
  {
    "supported_features": {
      "permitted_synthesis_morphing": "ALL"
    },
    "name": "後鬼",
    "speaker_uuid": "0f56c2f2-644c-49c9-8989-94e11f7129d0",
    "styles": [
      {
        "name": "人間ver.",
        "id": 27
      },
      {
        "name": "ぬいぐるみver.",
        "id": 28
      }
    ],
    "version": "0.14.1"
  },
  {
    "supported_features": {
      "permitted_synthesis_morphing": "ALL"
    },
    "name": "No.7",
    "speaker_uuid": "044830d2-f23b-44d6-ac0d-b5d733caa900",
    "styles": [
      {
        "name": "ノーマル",
        "id": 29
      },
      {
        "name": "アナウンス",
        "id": 30
      },
      {
        "name": "読み聞かせ",
        "id": 31
      }
    ],
    "version": "0.14.1"
  },
  {
    "supported_features": {
      "permitted_synthesis_morphing": "ALL"
    },
    "name": "ちび式じい",
    "speaker_uuid": "468b8e94-9da4-4f7a-8715-a22a48844f9e",
    "styles": [
      {
        "name": "ノーマル",
        "id": 42
      }
    ],
    "version": "0.14.1"
  },
  {
    "supported_features": {
      "permitted_synthesis_morphing": "ALL"
    },
    "name": "櫻歌ミコ",
    "speaker_uuid": "0693554c-338e-4790-8982-b9c6d476dc69",
    "styles": [
      {
        "name": "ノーマル",
        "id": 43
      },
      {
        "name": "第二形態",
        "id": 44
      },
      {
        "name": "ロリ",
        "id": 45
      }
    ],
    "version": "0.14.1"
  },
  {
    "supported_features": {
      "permitted_synthesis_morphing": "ALL"
    },
    "name": "小夜/SAYO",
    "speaker_uuid": "a8cc6d22-aad0-4ab8-bf1e-2f843924164a",
    "styles": [
      {
        "name": "ノーマル",
        "id": 46
      }
    ],
    "version": "0.14.1"
  },
  {
    "supported_features": {
      "permitted_synthesis_morphing": "ALL"
    },
    "name": "ナースロボ＿タイプＴ",
    "speaker_uuid": "882a636f-3bac-431a-966d-c5e6bba9f949",
    "styles": [
      {
        "name": "ノーマル",
        "id": 47
      },
      {
        "name": "楽々",
        "id": 48
      },
      {
        "name": "恐怖",
        "id": 49
      },
      {
        "name": "内緒話",
        "id": 50
      }
    ],
    "version": "0.14.1"
  }
]
```

</div></details>


音声合成はrun.exeを起動し、サーバーを立てた状態でそのサーバーに対してPOSTメソッドを行うことで実行しました。そのため、run.exeの実行は非同期処理として`subprocess.Popen()`を用いました。
POSTメソッドは2回行い、1回目はテキストを送信し音声合成用のクエリを作成するために、2回目はそのクエリを送信し音声を作成するために実行しました。
また、クエリを送信する前処理として`json.dumps`を用い、JSON文字列としました（JSONのプロパティ名はダブルクォートで囲まれている必要がある）。

取得した音声はwavファイルとして保存しました。
生成された音声について、サンプリングレートを`24000Hz`、チャネル数を1（つまりモノラル）、1回のサンプリングで使用するデータサイズを2バイト（つまりCD音質）として`writeframes`でwavファイルに書き込みました。

<details><summary>コードはこちら</summary><div>

```python
import subprocess
import json
import requests
import wave

proc = subprocess.Popen([r"C:\Users\hoge\AppData\Local\Programs\VOICEVOX\run.exe", "--use_gpu"])

speaker = 3 # ずんだもん
text = '異世界はスマートフォンとともに。'

params = (
    ('text', text),
    ('speaker', speaker),
)

# クエリの生成
response1 = requests.post(
    'http://localhost:50021/audio_query',
    params=params
)

# 合成音声の生成
response2 = requests.post(
    'http://localhost:50021/synthesis',
    params=params,
    data=json.dumps(response1.json())
    )

# wavファイルの書き込み
wf = wave.open("Ondoku.wav", 'wb')
wf.setnchannels(1) # チャネル数
wf.setsampwidth(2) # 1回のサンプリングで使用するデータサイズ
wf.setframerate(24000) # サンプリングレート
wf.writeframes(response2.content) # 書き込み
wf.close()

proc.kill()
```
</div></details>

## 音声合成と音声再生の並列処理
音声合成と音声再生の並列処理について、`音声合成時間 > 音声再生時間`であった場合、音声が合成されるまで再生ができないということになるため、`音声合成時間 < 音声再生時間`であることがスムーズな読み聞かせの条件であると言えます。
そこで、一度に合成する文章量の閾値を設定するために、合成のための文字数閾値、再生時間と合成時間の関係性について実験を行い、閾値の設定を行いました。

実験の詳細は以下です。
* 音声合成は文章単位で行う。
* 文章の文字数が文字数閾値未満であった場合は、その次の文章と結合し、総計が文字数閾値以上となった時に音声合成を行う。
* 文字数閾値を5～250の間で5ずつ変動させ、反復を5回として音声合成を行い、その合成時間、再生時間について調べた。
* 再生時間から合成時間を引いた差分と、文字数閾値についてグラフへのプロットを行った。
* `再生時間 - 合成時間`が正の値であることが、スムーズな読み聞かせの条件

以下のグラフより、文字数閾値が40以上であれば良さそうですね。
今回は余裕をもって閾値を80とします（勿論この結果はPCの性能に依存します）。
![グラフ.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1974522/bad6123a-9be3-2adf-11b1-03818ae0366e.png)

## 実装
というわけで完成品は以下のようになりました。
読み聞かせして欲しいなろう小説について欲しい話数分だけスクレイピングしてtxtファイルに保存します。
その後、先に行った実験で決定した文字数閾値以上の単位で文章の合成を行い、それと並列して音声の再生を行います。音声合成と音声再生の並列処理には`threading.Thread`を用いました。
順繰りに読んで欲しいため、作成したwavファイル名には順番を含めています。
また、寝落ちた後も読み聞かせをされるとそれは快眠とは言えないため、読み聞かせの時間を制限しその時間を超えたら音声の合成、再生を終了するようにしています。

```python
import json
import requests
import wave
import subprocess
import winsound
import time
import threading
import os
import glob
from bs4 import BeautifulSoup

def generate_wav(text, order):
    '''
    wavの生成
    '''
    speaker = 3 # ずんだもん

    params = (
        ('text', text),
        ('speaker', speaker),
    )

    # クエリの生成
    response1 = requests.post(
        'http://localhost:50021/audio_query',
        params=params
    )

    # 合成音声の生成
    response2 = requests.post(
        'http://localhost:50021/synthesis',
        params=params,
        data=json.dumps(response1.json())
    )

    # wavファイルの書き込み
    wf = wave.open(f"Yomikikase_{order}.wav", 'wb')
    wf.setnchannels(1) # チャネル数
    wf.setsampwidth(2) # 1回のサンプリングで使用するデータサイズ
    wf.setframerate(24000) # サンプリングレート
    wf.writeframes(response2.content) # 書き込み
    wf.close()

def create_sound():
    '''
    逐次、音声を合成する
    '''
    proc = subprocess.Popen([r"C:\Users\hoge\AppData\Local\Programs\VOICEVOX\run.exe", "--use_gpu"])

    order = 0
    line_index = 0
    proc_time = 0
    # 実行時間が指定の時間を超えたら終了
    while proc_time < limit_time:
        text = ""
        # 文章の文字数合計が閾値を超えたら合成開始
        while len(text) < letters_threshold:
            text += lines[line_index].replace("\n","")
            line_index += 1
        # wav生成
        generate_wav(text, order)
        order += 1

        print(text)
    
    proc.kill()

def play_sound():
    '''
    逐次、音声を再生する
    '''
    order = 0
    proc_time = 0
    time.sleep(15)
    # 実行時間が指定の時間を超えたら終了
    while proc_time < limit_time:

        filepath = "Yomikikase_{}.wav".format(order)
        winsound.PlaySound(filepath, winsound.SND_FILENAME)
        order += 1
        os.remove(filepath)

        time.sleep(0.2)
        proc_time = time.time() -start_time

def get_novel(target_url, num_episode=100):
    # User-Agentを変更
    headers = {
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/97.0.4692.71 Safari/537.36'
    }

    # ページ情報取得
    r = requests.get(target_url,headers=headers)
    soup = BeautifulSoup(r.text,'html.parser')

    # 小説タイトル
    title = soup.title.text

    # 話数取得
    index_num = min(len(soup.find('div', class_ = 'index_box').find_all('a')), num_episode)

    # 保存先テキストファイル
    save_path = f'{title}.txt'

    f = open(save_path, 'w', encoding='utf-8')
    f.write(title)

    # 各話のスクレイピング
    for i in range(1, index_num+1):
        # urlを取得
        target_url_ = target_url + f'{i}/'
        
        # ページ情報取得
        r = requests.get(target_url_,headers=headers)
        soup = BeautifulSoup(r.text,'html.parser')

        # 各話タイトル
        subtitle = soup.find('p', class_ = 'novel_subtitle')
        f.write(subtitle.text)

        # 本文
        honbun = soup.find('div', class_='novel_view')
        for i in honbun.find_all('p'):
            f.write(i.text+'\n')

    f.close()

    print('get novel')

    return title

if __name__ == '__main__':

    limit_time = 2400
    novel_url = 'https://ncode.syosetu.com/n1443bp/'
    letters_threshold = 80

    # 不要なwavを削除
    lst = glob.glob("Yomikikase_*.wav")
    for ls in lst:
        os.remove(ls)

    # 小説を取得
    title = get_novel(novel_url,10)
    with open(f'{title}.txt',encoding='utf-8') as f:
        lines = f.readlines()

    start_time = time.time()
    thread1 = threading.Thread(target=create_sound)
    thread2 = threading.Thread(target=play_sound)
    thread1.start()
    thread2.start()
```

# 使用感

実際に、ずんだもん に「異世界はスマートフォンとともに。」を読み聞かせしてもらい、使用感を確認しました。

結果として以下の2点が実証されました。
* 寝落ちることは可能
* ブルーライトから解放され、夜更かしは減少した

ただ以下のような問題点も確認されました。
* ずんだもんの声が高すぎる
* 読み間違いが散見される（主人公の名前の読み間違いが特に困る）
* イントネーションの違和感が割とある
* どこまで読み聞かせをしてもらったのかが不明

そのため、継続的な利用のためには、以下のような改善点があると感じました。
* 寝落ちに最適な声の検証（青山龍星とかいいかも）
* ある程度の調声
* 読み聞かせの詳細なログの出力

# まとめ
本記事では、VOICEVOXを用いた「なろう小説」の読み聞かせを作成し、その所感について述べました。
読み聞かせにより、画面を見ることなく読書が可能であることから、当初の課題であった、就寝前に「なろう小説」を読むことで発生する問題である**ブルーライトを浴び続け眠気が起きないことを解決できる可能性**は示されました。
ただ、①「ずんだもん」は声が高く読み聞かせに不適である点、②読み聞かせ精度が不十分であり、物語の世界観に没入できない点、③読了した箇所がわかりづらく2回目の使用が困難である点 が問題として挙げられ、本制作物は**継続的な利用という観点で多くの改善点を残している**と言えます。
よって、本制作物は日常的に使用することを考えた際に多くの課題を残しており、筆者は**kindle paperwhite**を使用し、ブルーライトレスな夜更かしをすることにしました。おわり

こんなしょうもない記事を読んで下さりありがとうございました。