nikkie-ftnextの日記

イベントレポートや読書メモを発信

声をPythonに聴かせて(マイクから入力した声をWhisperに、何度でも認識させよう)

はじめに

ういっすういっすういっすー!✌️ ぱー🖐 nikkieです。

シオン・プロジェクト、Whisper章の続編です。
観測範囲ではいろんな方がWhisperを触られてますね〜。
Whisperを使えば短いコードで音声ファイルを書き起こせます!
今回は「音声ファイルではなく、マイクから入力した音声は書き起こせるの?」という疑問にアプローチします。

目次

前回のシオン・プロジェクト!:環境構築

前回構築した環境を使っていきます。
ただし、whisperのソースコードを最新化したく、whisperだけ再インストールしました。

$ pip uninstall -y whisper
$ pip install git+https://github.com/openai/whisper.git

また、マイクの扱いに慣れているSpeechRecognitionをインストールしました。

$ pip install SpeechRecognition

動作環境

斜体が前回との差分です

結論:Whisperはマイクからも音声認識できます!

インメモリで扱うサンプルスクリプトです

from io import BytesIO

import numpy as np
import soundfile as sf
import speech_recognition as sr
import whisper

if __name__ == "__main__":
    model = whisper.load_model("base")

    recognizer = sr.Recognizer()
    while True:
        # 「マイクから音声を取得」参照
        with sr.Microphone(sample_rate=16_000) as source:
            print("なにか話してください")
            audio = recognizer.listen(source)

        print("音声処理中 ...")
        # 「音声データをWhisperの入力形式に変換」参照
        wav_bytes = audio.get_wav_data()
        wav_stream = BytesIO(wav_bytes)
        audio_array, sampling_rate = sf.read(wav_stream)
        audio_fp32 = audio_array.astype(np.float32)

        result = model.transcribe(audio_fp32, fp16=False)
        print(result["text"])

「何度でも認識させよう」のモチベーション

「Whisperは、音声ファイルだけではなく、マイクから入力した音声も書き起こせるの?」というのは、多くの方が疑問に思うところではないかと思います。
実際discussionsには、マイクを通して使う項目が挙がっています。

マイクから入力した音声の認識、既存のアプローチ

上記のdiscussionで共有されるアプローチは、マイクから入力した音声を一度ファイルに保存してWhisperに渡す1と理解しています。

Whisperは音声ファイルを書き起こせるので、マイクからの音声も音声ファイルにすれば書き起こせるわけですね!
目的は達成できているので、これは素晴らしい実装だと思います👍
一方で、シオン・プロジェクトの経験から「一度音声ファイルに保存する」という点はブラッシュアップできると気付きました。

マイクから入力した音声の認識、提案したいアプローチ

これまでの経験から、「一度ファイルに出力するのは必須ではない」と考えています。
過去に、マイクから入力した音声を一時ファイルに保存 -> それを読み込んで音声認識という実装から、一時ファイルを除く修正をしました。

インメモリストリームで一時ファイルが不要と気づく

メモリと比べると、ファイルの読み書きは遅いと認識しています(ディスクアクセスのため)。
音声を何度でも認識させる際には、ファイルの読み書きをなくすことで処理は速くなり、よりよいユーザ体験となると考えます。
以上の理由で、私は音声をファイルに出力しない(インメモリで扱う)アプローチを推します

音声データの渡し方を調査:transcribeメソッドは何をしているのか

ファイル出力を挟まず、データをメモリ上で変換してWhisperに文字起こしさせられるかを知るため、ソースコードを見ていきます。
出発点は環境構築の記事で動かした以下のコードです。

>>> import whisper
>>> model = whisper.load_model("base")
>>> result = model.transcribe("sample.wav", fp16=False)

load_model関数でWhisperのモデルを読み込み、モデルのtranscribeメソッドを呼んでいます。

whisper.load_model

load_model関数は__init__.pyに定義されています。
https://github.com/openai/whisper/blob/9f70a352f9f8630ab3aa0d06af5cb9532bd8c21d/whisper/__init__.py#L68

def load_model(name: str, device: Optional[Union[str, torch.device]] = None, download_root: str = None, in_memory: bool = False) -> Whisper:

型ヒントによると、返り値はWhisperクラスのインスタンスです。
importを見ると、このクラスはmodel.pyに定義されている模様。

whisper.model.Whisper.transcribe

WhisperクラスはPyTorchのnn.Moduleを継承したクラスとなっています2

class Whisper(nn.Module):

transcribeメソッドの定義は以下
https://github.com/openai/whisper/blob/9f70a352f9f8630ab3aa0d06af5cb9532bd8c21d/whisper/model.py#L266

    transcribe = transcribe_function

importを見ると、transcribe_functiontranscribe.pytranscribe関数のようです。

whisper.transcribe.transcribe

transcribe関数の定義を見ていきます。
https://github.com/openai/whisper/blob/9f70a352f9f8630ab3aa0d06af5cb9532bd8c21d/whisper/transcribe.py#L19-L22

def transcribe(
    model: "Whisper",
    audio: Union[str, np.ndarray, torch.Tensor],
    *,
    # 以下略

私たちはmodel.transcribe("sample.wav")と、音声ファイルのパスを表す文字列を実引数として渡す使い方を知っているのでした。
対応する仮引数は、上のコードのaudioです。
型ヒントを見ると、文字列のほか、NumPy arrayやPyTorchのTensorも渡せることが分かります!3
ここで、音声ファイルのパスだけでなく、音声データ自体も渡せそうと見通しが立ちました。
どのように変換すればいいかが気になってきますが、もう少し仮引数audioの扱いを見ていきましょう。

transcribe関数の実装の中でaudioが登場するのは以下の箇所のみでした。
https://github.com/openai/whisper/blob/9f70a352f9f8630ab3aa0d06af5cb9532bd8c21d/whisper/transcribe.py#L84

    mel = log_mel_spectrogram(audio)

次に見るのはlog_mel_spectrogram関数です。
importを見ると、audio.pyにあることが分かります。

whisper.audio.log_mel_spectrogram

log_mel_spectrogram関数の定義はどうなっているのでしょうか。
https://github.com/openai/whisper/blob/9f70a352f9f8630ab3aa0d06af5cb9532bd8c21d/whisper/audio.py#L92

def log_mel_spectrogram(audio: Union[str, np.ndarray, torch.Tensor], n_mels: int = N_MELS):

最初の仮引数audioは文字列、NumPy array、PyTorchのTensorのいずれかを受け取ります4

audioの処理は以下のように行われます(audioへの再代入ですね):
https://github.com/openai/whisper/blob/9f70a352f9f8630ab3aa0d06af5cb9532bd8c21d/whisper/audio.py#L109-L112

    if not torch.is_tensor(audio):
        if isinstance(audio, str):
            audio = load_audio(audio)
        audio = torch.from_numpy(audio)

audioが指す値が

  • PyTorchのTensorの場合
    • 上記の箇所は何もしません。Tensorのままです
  • NumPyのarrayの場合
    • torch.from_numpyTensorに変わります
  • strの場合
    • load_audio関数(後述)によって読み込まれます
    • load_audio関数はNumPyのarrayを返します
    • 続く処理によってTensorに変わります

まとめると、上記4行の処理によりaudioはPyTorchのTensorに揃います

whisper.audio.load_audio

ffmpegを使って音声ファイルを読み込む関数です。
https://github.com/openai/whisper/blob/9f70a352f9f8630ab3aa0d06af5cb9532bd8c21d/whisper/audio.py#L22-L49

この関数を使う代わりに、マイクから取得した音声データを扱う方法を考えます。

マイクから取得した音声データの扱い方

マイクから音声を取得

マイクから音声を取得するのに、これまでのシオン・プロジェクトで慣れているSpeechRecognitionを使います。

import speech_recognition as sr

r = sr.Recognizer()
with sr.Microphone(sample_rate=16_000) as source:
    print("なにか話してください")
    audio = r.listen(source)
    print("音声を取得しました")

音声データをWhisperの入力形式に変換

これまでのシオン・プロジェクトから、SpeechRecognitionで取得した音声データをインメモリストリームを使ってNumPyのarrayに変換する方法は分かっています5
この方法ではarrayのdtypenumpy.float64です。

これに対して、whisperのload_audio関数の返り値のNumPy arrayは、dtypenumpy.float32です。
load_audio関数に音声ファイルを渡して返り値のdtypeを確認しています6

dtypeを揃える必要がありそうですよね。
numpy.float64からnumpy.float32に変換したNumpy arrayをtranscribeメソッドに渡してみましょう。

from io import BytesIO

import numpy as np
import soundfile as sf
import speech_recognition as sr

r = sr.Recognizer()
with sr.Microphone(sample_rate=16_000) as source:
    print("なにか話してください")
    audio = r.listen(source)

    print("音声処理中 ...")
    wav_bytes = audio.get_wav_data()
    wav_stream = BytesIO(wav_bytes)
    audio_array, sampling_rate = sf.read(wav_stream)
    audio_fp32 = audio_array.astype(np.float32)

    result = model.transcribe(audio_fp32, fp16=False)

Whisperは動き、音声認識しました!!🙌

ファイル出力を使った場合と同じ入力になっているか検証

SpeechRecognitionのMicrophoneを使って取得した音声データをNumPy arrayとして加工し、Whisperに渡す方法が分かりました。
最後に、音声データをファイル出力してパスをWhisperに渡す既存のアプローチと同じNumPy arrayになっているか検証します。

検証スクリプト

2つのNumPy arrayが等しいか、assert_array_equalで検証します7

  • マイクから入力した音声を保存して読み込んだNumPy array(=load_audio関数の返り値)
  • インメモリストリームを使ったNumPy arrayの変換

検証結果

$ python validation.py
検証します。話してください
検証開始
(36864,) (36864,)

数パターン話してみましたが、NumPy arrayのassertionで例外は送出されませんでした。
Whisperでの認識結果と、この検証スクリプトから、メモリ上での音声データの変換は実装できていると結論づけました。

ただ、私自身は音声の扱いは入門者です。
この記事を読んで気になる点がありましたら、@ftnextまでフィードバックいただけますと大変助かります。

終わりに

Whisperでの音声認識、ファイルを渡すのではなく、マイクから入力した声を認識させる方法を見てきました。
パッとやるならファイル出力をしてそのファイルパスを渡す方法ですが、私が推すのはメモリ上で音声データ(NumPy array)を変換する方法です!
Whisperの実装をたどるなかでメモリで処理するアイデアが実現できそうということが見えてきて、実際に動かせ、検証も通った瞬間は、大きくガッツポーズでした8

波形データの扱いはブラックボックスすぎるのですが、この実装の確度を高めるためにも「どこかでキャッチアップしたいな〜」と思っています。
例えばlog_mel_spectrogramの単語の意味を理解したいですね。メル?? スペクトログラム??😵
学習リソースのオススメが浮かんだ方は教えていただけるととても助かります。

ここまででWhisperの扱いは完全に理解しました(※理論は後回しにしています)。
新章はこの後、どこへ進むのか? 理論を追うのか、Whisperを組み込んだシオンv0.0.2が誕生するのか、お楽しみに!

P.S. もう一つのモチベーション

SpeechRecognitionのコラボレータとして活動を始めているのですが、WhisperをサポートするPull requestをいただきました👏

この実装のアプローチも「一度音声ファイルに保存する」アプローチです(tempfile使用)。
このプルリクエスト自体はやりたいことが実現できているのでマージできる(マージしたい)👍と考えますが、一方でこの記事で説明した、私自身の経験からのブラッシュアップも浮かぶので、そこはパッチとして提案していきたいとも思っています。

というわけで、この記事で紹介した実装は近日中にSpeechRecognitionに入れる考えです。お楽しみに!


  1. https://github.com/mallorbc/whisper_mic/blob/9a4826d525890e6e5468048b58847328be48652b/mic.py#L40

  2. https://github.com/openai/whisper/blob/9f70a352f9f8630ab3aa0d06af5cb9532bd8c21d/whisper/model.py#L197

  3. docstringには「The path to the audio file to open, or the audio waveform(開くべき音声ファイルのパス、または音声波形)」ともあります

  4. docstringには「The path to audio or either a NumPy array or Tensor containing the audio waveform in 16 kHz(音声のパスまたは、16kHzの音声波形を含むNumpy arrayかTensor)」とあります

  5. 新編を参照ください

  6. この記事にまとめる中でload_audio関数でnumpy.float32に変換しているのに気付きました https://github.com/openai/whisper/blob/9f70a352f9f8630ab3aa0d06af5cb9532bd8c21d/whisper/audio.py#L49

  7. このトーク https://pycon.jp/2020/timetable/?id=203957 がオススメです。(WEB+DB PRESS連載「現場のPython」でも取り上げられていたかも)

  8. 脳内に響き渡った「やったなニッキー!!」「わあああああ👏👏👏👏👏」