【Python】VOSKによるスピーカーからの音声出力のオフライン文字起こし

# 概要
ZoomやYouTubeなどスピーカーまたはヘッドフォンから流れてくる音を逐一オフラインで文字起こしするプログラムを作成しました。

# モチベーション
Web会議などで長々と話されると、ついつい集中が切れてしまい聞き逃してしまうことが多々あります。そこで **「発言をすべて文字起こししてしまえば、聞き逃さないのではないか！」** と思い立ち、今回の取り組みを始めました。
また、マイクロフォンや音声ファイルからの文字起こしのやり方の情報は豊富にある（[公式のサンプルコード](https://github.com/alphacep/vosk-api/tree/master/python/example)）一方で、スピーカーからの文字起こしはなかなか見当たらなかったので、今回Qiitaの記事にしてみました。

# デモ
[VOICEVOX](https://voicevox.hiroshiba.jp/)で出力した音声をオフラインで文字起こしします。
＊画質は **480p以上** でないと、PowerShell（ターミナル）に出力される認識結果が見えづらいかもしれません。
<iframe width="560" height="315" src="https://www.youtube.com/embed/8TGZBzI9u7E" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>

# ニュースの文字起こし結果
[天の川銀河「巨大ブラックホール」の撮影に初成功　国立天文台など国際チーム(2022年5月13日)](https://www.youtube.com/watch?v=xcvF9XBSi9M)というYouTubeのニュースを文字起こししてみました。ニュースの原稿中で正しく文字起こしされた文字数をカウントしたところ、精度は **95.6%** でした。
ニュースの原稿は以下の通りです。

```text
地球がある天の川銀河の中心にある巨大ブラックホールの撮影に初めて成功したと国立天文台などが参加する国際チームが発表しました。

明るいリングの内側の暗く黒い部分が巨大ブラックホールの輪郭です。

12日、日本の国立天文台なども参加する国際研究チームは、太陽系がある天の川銀河の中心にある「いて座Aスター」と呼ばれる巨大ブラックホールの撮影に初めて成功したと発表しました。

このブラックホールは地球からおよそ2万7000光年の距離にあり、質量は太陽の400万倍ほどだということです。

ブラックホールの強い重力に引き寄せられた物質が周囲を高温・高速で回転し、明るい輪のように見え、中心にある本来撮影できないはずのブラックホールの輪郭が浮かび上がっているということです。

この画像は、世界各地の8つの電波望遠鏡をつないでデータを取得し、それを処理した数千枚の画像をおよそ5年かけて解析し1枚にまとめたものです。

日本の研究者はこのデータ分析や画像処理にも貢献したということです。

研究チームは2019年にM87銀河の中心にある巨大ブラックホールの撮影にも成功しています。

M87のブラックホールの質量は、今回撮影された「いて座Aスター」の1500倍重いということです。
```

文字起こしした結果は以下の通りです。

```text
時給天の川銀河の中心にある巨大ブラックホールの撮影に初めて成功したと国立天文台などが参加する国際チームが発表しました
                                                                                
明るいリンクの内側の暗く黒い部分が巨大ブラックホールの輪郭です

中に日日本の国立天文台なども参加する国際研究チームは太陽系がある天の川銀河の中心にある射手座さあと呼ばれる巨大ブラックホールの撮影に初めて成功したと発表しました

このブラックホールは地球からおよそ二万七千ねの距離にあり質量は太陽の四百万倍ほどだということです

ブラックホールの強い重力に引き寄せられた物質が周囲を被高速で回転し明るいはのように見え中心にある本来撮影できないはずのブラックホールの輪郭が浮かび上がっているということです

この画像は世界各地のやつの電波望遠鏡を繋いでデータを取得しそれを処理した数千枚の画像およそ五年かけて解析し一枚にまとめたものです
    
日本の研究者はこのデータ分析や画像処理にも貢献したということです          

研究チームは二千十九年にＭ八十七銀河の中心にある巨大ブラックホールの撮影にも成功しています   

Ｍ八十ならのブラックホールの質量は今回撮影されたいて座エーススターの一千五百倍思いということです
```

# 動作環境
以下の環境で動作を確認しました。
- Windows 10
- Ubuntu 18.04

macOSでも動くとは思いますが、手元に環境がないため確認できていません。

# SoundCard
システム音を録音するのには、SoundCardライブラリ（[PyPI](https://pypi.org/project/SoundCard/)、[GitHub](https://github.com/bastibe/SoundCard)）を使います。詳細は前回記事を書いたので、[【8行】Pythonでシステム音を録音する方法](https://qiita.com/3998/items/fe7bf6f0a3be20cafdd8)をご覧ください。

# VOSK
今回の音声認識ではVOSK（[ドキュメント](https://alphacephei.com/vosk/)、[GitHub](https://github.com/alphacep/vosk-api)）を使います。VOSKは音声認識だけではなく、音声区間検出（いつ話しているかの判断）もやってくれます。そのため、録音したデータをVOSKに流し込むだけで、ちょうどいい区切りのところで認識結果をテキストとして返してくれます。
VOSKは無料で使えます。より精度の高いモデルを使いたい場合は、開発者に相談する必要があるようです。今回は無料版を使います。
voskという名前のライブラリがあり、pipを使ってインストールできます（[PyPI](https://pypi.org/project/vosk/)、[GitHub](https://github.com/alphacep/vosk-api)）。モデルは[公式サイト](https://alphacephei.com/vosk/models)からダウンロードできます（事前にモデルをダウンロードしなくても、プログラム中でモデルがなければ自動でダウンロードしてくるようにすることもできます。詳細は音声認識のソースコードを参照してください）。

# 設計
1. 短期間（今回は0.4秒間）の録音をし続けるプロセスを作成します。
1. メインのプロセスはVOSKを使って、音声認識をし続けます。
1. 録音するプロセスとメインのプロセスはキューでデータをやり取りでき、録音したデータは随時録音するプロセスからメインのプロセスに送られます。
1. メインのプロセスで認識した結果はPowerShell（ターミナル）に表示します。

# 実装
ソースコード全体は[GitHub](https://github.com/tez3998/audio-output-to-text)に置いてあります。

:::note warn
注意
公式のリポジトリでvoskライブラリの作者が[メソッド名の変更を計画しているようなIssue](https://github.com/alphacep/vosk-api/issues/1099)を出してます。
メソッド名の変更があれば、下記のコードも変更するつもりですが、すぐに対応できない場合は下記のコードが動かない場合があります。
:::

## 録音
スピーカーやヘッドフォンから出力される音を録音し続けます。録音した音声データはキューに入れます。

```run.py
import copy
import multiprocessing as mp
import soundcard as sc

def capture_audio_output(audio_queue: mp.Queue, # 音声認識するプロセスにデータを受け渡すためのキュー
                         capture_sec: float, # 1回のループで録音する時間
                         sample_rate: int) -> None: # サンプリング周波数
    
    num_frame: int = int(sample_rate * capture_sec)
    
    while True:
        audio = sc.get_microphone(include_loopback=True, id=str(sc.default_speaker().name)) \
            .record(numframes=num_frame, samplerate=sample_rate, blocksize=sample_rate)
        audio_queue.put(copy.copy(audio[:, 0])) # 音声認識する際はモノラルで良いためaudio[:, 0]としています
```

## 音声認識
キューから音声データを取り出し、音声認識をし続けます。

```run.py
import json
import multiprocessing as mp
import numpy as np
import vosk

def speech_to_text(audio_queue: mp.Queue, # 録音するプロセスから音声データを貰うためのキュー
                   sample_rate: int) -> None: # サンプリング周波数
    NO_LOG: int = -1 # VOSK関連のログを出さないためのフラグ
    MODEL_PATH = "model" # モデルのあるディレクトリまでのパス
    
    vosk.SetLogLevel(NO_LOG)
    
    # 以下のようにするとモデル名を指定すると、モデルが/Users/User/.cache/vosk/（パスはWindowsの場合）になければ自動でダウンロードしてくれ、
    # さらに次回以降別のディレクトリでプログラムを実行してもモデルを移動せずにプログラムを実行することができます。
    # model: vosk.Model = vosk.Model(model_name="vosk-model-ja-0.22")
    model: vosk.Model = vosk.Model(model_path=MODEL_PATH)
    recognizer = vosk.KaldiRecognizer(model, sample_rate)
    
    print("Recognizer is ready")
    print("Output sound from a speaker or a headphone")
    print("#" * 40)
    
    while True:
        audio = audio_queue.get()
        # 音声データを認識に使える形に変換
        audio = map(lambda x: (x+1)/2, audio)
        audio = np.fromiter(audio, np.float16)
        audio = audio.tobytes()
        
        if recognizer.AcceptWaveform(audio): # 音声データの読み込み、話しがちょうどいい区切りの場合、1を返す
            result: json = json.loads(recognizer.Result())
            text = result["text"].replace(" ", "")
            
            if text != "":
                print(text)
```

## メイン関数
キューとプロセスを作成します。

```run.py
import multiprocessing as mp
import sounddevice as sd

def main():
    CAPTURE_SEC: int = 0.4 # 録音するプロセスが1回のループで録音する時間
    
    audio_queue: mp.Queue = mp.Queue() # 録音するプロセスと音声認識するプロセスがやり取りするためのキュー
    sample_rate: int = int(sd.query_devices(kind="output")["default_samplerate"]) # サンプリング周波数は、システムの音声出力の周波数を利用
    stt_proc: mp.Process = mp.Process(target=speech_to_text,
                                      args=(audio_queue, sample_rate)) # 録音するプロセスの作成
    
    print("Type Ctrl+C to stop")
    
    stt_proc.start()
    
    try:
        capture_audio_output(audio_queue=audio_queue, capture_sec=CAPTURE_SEC, sample_rate=sample_rate)
        stt_proc.join()
    except KeyboardInterrupt:
        stt_proc.terminate()
        
        print("\nDone")
```

# 動かし方
## 日本語モデルのダウンロード
VOSKの開発元である[Alpha Cepheiの公式サイト](https://alphacephei.com/vosk/models)からダウンロードします。2022年7月16日現在、最新の日本語モデルの名前は **vosk-model-small-ja-0.22** ですので、こちらをダウンロードし解凍します（認識精度をより良くしたい場合は、 **vosk-model-ja-0.22** を選択します）。そして、解凍したモデルのディレクトリ名を **model** に変更し、 ソースコードと同じディレクトリに配置します。
## 実行
ソースコードを実行します。

```bash
python run.py
```

ターミナルに以下が表示されれば、プログラムは正常に動作しています。

```bash
Type Ctrl+C to stop
Recognizer is ready
Output sound from a speaker or a headphone
########################################
```

YouTubeのニュースなどパソコンからの音を出力すると、その内容が文字起こしされます。

# 使い心地
ニュースなど話し方がハキハキしていて一般的な言葉を多く使っている場合は、なかなか良い精度で文字起こししてくれます。一方、Web会議などモゴモゴと話されたり特殊な用語を使われると誤認識が目立ち始めます。誤認識がある場合でも、誤認識を頭の中で音に変換すれば元の文字が何だったのかなんとなく分かることが多い印象です。そのため、一瞬だけ聞き逃した程度であれば役立つと思います。

# 上手く動かなかったところ
今後のGUI化に向けて、音声認識も録音と同様にmultiprocessingで作成したプロセスで動かしたいと思っていました。しかし、そのようにすると、Windows 10では動くのですがUbuntu 18.04では以下のようなエラーが出てしまいます。

```bash
Assertion 'o' failed at pulse/operation.c:67, function pa_operation_unref(). Aborting.
```

Linuxでは、SoundCardはPulseAudioを使うのですが、どうやらその際に出ているエラーのようです。[SoundCardのGitHubのIssue](https://github.com/bastibe/SoundCard/issues/96)でも言及されていますが、まだオープンのままです。

# Python以外の言語のサポート
VOSKはPython以外に、CやC#、Go、Swift、Java、JavaScriptなどもサポートしています。詳細は[公式のGitHub](https://github.com/alphacep/vosk-api)をご覧ください。

# 今回紹介した以外のVOSKの使い方を知りたい方へ
マイクロフォンからの音声入力や音声ファイルから文字起こしする方法は、[公式のサンプルコード](https://github.com/alphacep/vosk-api/tree/master/python/example)をご覧ください。話者認識（誰が今話しているかの判定）の機能もあり、動かしてみるだけでもとても面白いです。