pythonで音に合わせて動く波形（オーディオスペクトラム）を作ってみる

#はじめに

現状macにはオーディオスペクトラム(よくある音に合わせてぬるぬる動く周波数領域の波形)を使えるフリーソフトがないようです。そこで、pythonを使って自作して遊んでみます。

(windowsの方はAviUtlというフリーソフトで出来るようです。)


##状況と目的

手元には、音声波形を作りたい**wav形式の**ファイルがあります。(僕の場合はGarageBandで打ち込んだ曲の出力ファイルです。）これを動画形式にしたいのですが、静止画に音声だけ流れるような動画では少々味気ないです。

そこで、作った曲に合わせて動く**オーディオスペクトラム**を作って、多少なりとも動画映えするようにするのが今回の目的です。

こんな感じのものが作れます↓
https://www.youtube.com/watch?v=JPE54SlF6H0
[![【ポケモン剣盾】戦闘！ビート【8bit音源アレンジ】](http://img.youtube.com/vi/JPE54SlF6H0/0.jpg)](http://www.youtube.com/watch?v=JPE54SlF6H0)
![AudioVissualizer.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/534823/4795ad5b-226c-d1f5-c349-f08f6e795024.gif)

#1. 環境について
OS：macOS High Sierra 10.13.6
使用言語：Python 3.7.4

標準ライブラリ以外だと、

- [PyGame](https://www.pygame.org/wiki/GettingStarted)(ゲームエンジンですが、単に表示用のGUIのような感覚で使います)
- [PyAudio](https://pypi.org/project/PyAudio/)(wavファイルの再生に使います)
- [PySoundFile](https://pysoundfile.readthedocs.io/en/latest/#)(wavファイルのデータ読み取りに使います)
- **SciPy**(高速フーリエ変換に使います←NumPyより高速っぽい？)
- **NumPy**

のインストールが必要です。基本的にはpip(pip3)でOKだと思います。
NumPyしかわからない、という方でも読めるように書いていくつもりです（僕がそうなので）。


#2. サンプルコード

ピンク背景に水色の波が動くだけのサンプルコードです。
プログラムと同階層に`sample.wav`という名前の音源を用意していただければとりあえず動かせると思います。モノラルステレオはかなり雑ですが両対応です。[こんな感じのフリー音源](https://on-jin.com/sound/listshow.php?pagename=ta&title=%E3%82%B3%E3%83%B3%E3%83%88%E3%81%AE%E3%82%AA%E3%83%8102%EF%BC%88%E3%83%81%E3%83%A3%E3%83%B3%E3%83%81%E3%83%A3%E3%83%B3%EF%BC%89&janl=%E3%81%9D%E3%81%AE%E4%BB%96%E9%9F%B3&bunr=%E3%83%90%E3%83%A9%E3%82%A8%E3%83%86%E3%82%A3&kate=%E3%81%9D%E3%81%AE%E4%BB%96)とかでも遊べます。

全体を載せたあと、ちょっとずつ細かく見ていけたらと思います。

```python:SampleAudioVisualizer.py
#!/usr/bin/env python3
import wave
import sys
import pygame
from pygame.locals import *
import scipy.fftpack as spfft
import soundfile as sf
import pyaudio
import numpy as np

# --------------------------------------------------------------------
# パラメータ
# --------------------
fn = "sample.wav"
# 計算用
CHUNK = 1024  # pyaudioでストリームにチャンク単位で出力(何故1024かはよく知らない)
start = 0  # サンプリング開始位置
N = 1024  # FFTのサンプル数
SHIFT = 1024  # 窓関数をずらすサンプル数
hammingWindow = np.hamming(N)  # 窓関数

# --------------------
# 描画用
SCREEN_SIZE = (854, 480)  # ディスプレイのサイズ
rectangle_list = []

# --------------------
# pygame画面初期設定
pygame.init()
screen = pygame.display.set_mode(SCREEN_SIZE)
pygame.display.set_caption("Pygame Audio Visualizer")

# --------------------------------------------------------------------
# wavファイルを再生しつつ、後に定義する再描画関数redraw()を呼び出す関数
def play_wav_file(filename):
    try:
        wf = wave.open(filename, "r")
    except FileNotFoundError:  # ファイルが存在しなかった場合
        print("[Error 404] No such file or directory: " + filename)
        return 0

    # ストリームを開く
    p = pyaudio.PyAudio()
    stream = p.open(format=p.get_format_from_width(wf.getsampwidth()),
                    channels=wf.getnchannels(),
                    rate=wf.getframerate(),
                    output=True)

    # 音声を再生
    data = wf.readframes(CHUNK)
    while data != '':
        stream.write(data)
        data = wf.readframes(CHUNK)
        redraw()
    stream.close()
    p.terminate()

# --------------------------------------------------------------------
# 「FFTかけて描画」を繰り返す．
def redraw():
    global start
    global screen
    global rectangle_list

    # --------------------
    # 対象サンプル点のブロックにFFTをかけて振幅スペクトルを計算する
    windowedData = hammingWindow * x[start:start + N]  # 窓関数をかけたデータブロック
    X = spfft.fft(windowedData)  # FFT
    amplitudeSpectrum = [np.sqrt(c.real ** 2 + c.imag ** 2)
                         for c in X]  # 振幅スペクトル

    # --------------------
    # Pygameでの描画

    screen.fill((240, 128, 128))  # 好きな色で初期化する
    rectangle_list.clear()  # 四角形リスト初期化
    # スペクトル描画 数値は実行して調整しながら
    for i in range(86):
        rectangle_list.append(pygame.draw.line(screen, (102, 205, 170), (1+i * 10, 350 + amplitudeSpectrum[i] * 1),
                                               (1+i * 10, 350 - amplitudeSpectrum[i] * 1), 4))

    pygame.display.update(rectangle_list)  # ディスプレイ更新

    start += SHIFT  # 窓関数をかける範囲をずらす
    if start + N > nframes:
        sys.exit()

    for event in pygame.event.get():  # 終了処理
        if event.type == QUIT:
            sys.exit()
        if event.type == KEYDOWN:
            if event.key == K_ESCAPE:
                sys.exit()

# --------------------------------------------------------------------
if __name__ == "__main__":

    # --------------------
    # wavデータを取得
    data, fs = sf.read(fn)  # dataの形状は(フレーム数×チャンネル数)
    if data.ndim == 1:
        x = data  # モノラルならそのまま使う
    if data.ndim == 2:
        x = data[:, 0]  # ステレオならLチャンネルだけに絞って処理することに(Rなら0を1にしてください)

    nframes = x.size  # フレーム数取得(FFTで窓関数をずらすときの終了条件に使います)

    # --------------------
    # 再生と描画を開始
    play_wav_file(fn)
# --------------------------------------------------------------------
```


#3. 実装の流れ



wav形式のデータ部分は、**1/fs** 秒 (fs:サンプリング周波数[Hz])ごとの音の情報を保持している時系列データです。

> (追記)ちょっとでもイメージがつきやすいように、[先ほどのフリー音源](https://on-jin.com/sound/listshow.php?pagename=ta&title=%E3%82%B3%E3%83%B3%E3%83%88%E3%81%AE%E3%82%AA%E3%83%8102%EF%BC%88%E3%83%81%E3%83%A3%E3%83%B3%E3%83%81%E3%83%A3%E3%83%B3%EF%BC%89&janl=%E3%81%9D%E3%81%AE%E4%BB%96%E9%9F%B3&bunr=%E3%83%90%E3%83%A9%E3%82%A8%E3%83%86%E3%82%A3&kate=%E3%81%9D%E3%81%AE%E4%BB%96)のデータをプロットしてみます。(この音源はステレオなので、Lチャンネルだけとってみます)
 ![Figure_1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/534823/1f7f8b42-f71f-6aa1-edd1-05e0bdf97b24.png)
こんな感じで、このデータ(配列)には-1から+1までの値をとる波が入っていることがわかります。横軸は配列のインデックスです。この1要素ごとに、`1/fs`秒(ちなみにこの例では`fs=44.1[kHz]`です)の情報が表現されているわけですから、「時間軸で見た波形」ということになります。

> このグラフの横軸に`1/44100`をかけると、秒に変換できる、と言ったほうがわかりやすいかもしれませんね。

それに対しオーディオスペクトラムは、周波数領域のグラフが絶えず変化しているものです。**時間領域のデータはフーリエ変換することで周波数領域で見ることができる**ので、フーリエ変換をうまく使いながら進めることになりそうです。

そこで、

1. 超短時間のデータを読み取って…(インデックス0から1023まで)
2. その音声を**PyAudio**を使って再生しながら…
3. 読み取ったデータを高速フーリエ変換(**FFT**) & 変換後のスペクトルを**PyGame**で描画して…
4. また次の短時間のデータを読み取って…(インデックス1024から2047まで)
5. *(これをデータが終わるまで繰り返す)*

という処理を行えば良さそうですね。リアルタイムに音声再生とフーリエ変換をこまめに繰り返すイメージです。

>ちなみに、「短時間のデータ」としてwavのデータ点を1024個ずつずらしながら処理しようとしていますが、別に1024である必要性はありません。ただ、小さくしすぎると、再生よりも描画に時間がかかるようになるため挙動がおかしくなり、大きくしすぎると描画の更新が遅くなり滑らかさが失われてしまうので、注意が必要です。



##3-1. wavファイルの時系列データとその長さ(フレーム数)を取得する

メインルーチンのこの部分です。

```python:一部抜粋
import soundfile as sf
fn = "sample.wav"
# (略)
# --------------------------------------------------------------------
if __name__ == "__main__":

    # --------------------
    # wavデータを取得
    data, fs = sf.read(fn)  # dataの形状は(フレーム数×チャンネル数)
    if data.ndim == 1:
        x = data  # モノラルならそのまま使う
    if data.ndim == 2:
        x = data[:, 0]  # ステレオならLチャンネルだけに絞って処理することに(Rなら0を1にしてください)

    nframes = x.size  # フレーム数取得(後述のFFTで窓関数をずらすときの終了条件に使います)

    # --------------------
    # (略)
```

[PySoundFile](https://pysoundfile.readthedocs.io/en/latest/#)を使うとwavファイルをいい感じに扱えます。`read()`メソッドを使ってデータとその長さを取得できました。
(参考：[Pythonでのwavファイル操作](https://qiita.com/Dsuke-K/items/2ad4945a81644db1e9ff))


##3-2. wavファイルの再生処理

`play_wav_file()`という、`CHUNK`単位でストリームに書き出して音声を再生する関数を定義します。モジュールは[wave](https://docs.python.org/ja/3.7/library/wave.html)と[PyAudio](https://pypi.org/project/PyAudio/)を使います。

(参考：[【Python】Pyaudioでwavファイルを再生](https://shizenkarasuzon.hatenablog.com/entry/2018/12/31/145510))

基本的には参考にさせていただいた記事の通りなのですが、ストリームに書き出して次のデータを読み取る、というループ処理に`redraw()`という後述する自作関数をつっこんでいます。(再生と同時にオーディオスペクトラムを表示するため)

```python:一部抜粋
import wave
import pyaudio

# --------------------------------------------------------------------
# パラメータ
# --------------------
# 計算用
CHUNK = 1024  # pyaudioでストリームにチャンク単位で出力(何故1024かはよく知らない)

# ~中略~

# --------------------------------------------------------------------
# wavファイルを再生しつつ、後に定義する再描画関数redraw()を呼び出す関数

def play_wav_file(filename):
    try:
        wf = wave.open(filename, "r")
    except FileNotFoundError:  # ファイルが存在しなかった場合
        print("[Error 404] No such file or directory: " + filename)
        return 0

    # ストリームを開く
    p = pyaudio.PyAudio()
    stream = p.open(format=p.get_format_from_width(wf.getsampwidth()),
                    channels=wf.getnchannels(),
                    rate=wf.getframerate(),
                    output=True)

    # 音声を再生
    data = wf.readframes(CHUNK)
    while data != '':
        stream.write(data)
        data = wf.readframes(CHUNK)
        redraw() # 再描画用関数です　あとで作ります
    stream.close()
    p.terminate()

# ~後略~
```

##3-3. 対象サンプル点のブロックにFFTをかける

こちらの記事([短時間フーリエ変換 - 人工知能に関する断創録](http://aidiary.hatenablog.com/entry/20110716/1310824587))が非常にわかりやすく、参考にさせていただきました。

先ほど音声再生時の`CHUNK = 1024`としたので、それに合わせて高速フーリエ変換(以降FFTと表記)をかける対象サンプル数Nも1024とします。

データ全体から1024個のデータを取り出したら、そのままFFTをするのではなく**窓関数**をかけてからFFTを行います。理論めいた話になってきましたが、先ほどの記事でも紹介されていた[窓関数を用いる理由 - ロジカルアーツ研究所](https://www.logical-arts.jp/archives/124)というページにわかりやすくまとまっていますので、興味のある方はご覧になってみてください。

ここではメジャーな**ハミング窓**(`np.hamming()`)を用います。これをかけることで端っこが滑らかに繋がり、切り出したサンプルが周期関数になります。
![1024px-Window_function_(hamming).svg.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/534823/486f5005-a7a6-eec7-0295-df5501a9f446.png)

```python:一部抜粋
import sys
import scipy.fftpack as spfft
import numpy as np

# --------------------------------------------------------------------
# パラメータ
# --------------------
# 計算用
CHUNK = 1024  # pyaudioでストリームにチャンク単位で出力(何故1024かはよく知らない)
start = 0  # サンプリング開始位置
N = 1024  # FFTのサンプル数
SHIFT = 1024  # 窓関数をずらすサンプル数
hammingWindow = np.hamming(N)  # 窓関数

# ~中略~

# --------------------------------------------------------------------
# 「FFTかけて描画」を繰り返す．ここではFFTをかける処理だけを見てみます。
def redraw():
    global start
    # ~中略~

    # --------------------
    # 対象サンプル点のブロックにFFTをかけて振幅スペクトルを計算する
    windowedData = hammingWindow * x[start:start + N]  # 窓関数をかけたデータブロック
    # (↑のリストx[]は、本記事3-1で取り出したwavのデータです)
    X = spfft.fft(windowedData)  # FFT
    amplitudeSpectrum = [np.sqrt(c.real ** 2 + c.imag ** 2)
                         for c in X]  # 振幅スペクトル

    # --------------------
    # ここにPygameでの描画処理(ここでは省略)

    start += SHIFT  # 窓関数をかける範囲をずらす
    if start + N > nframes:
        sys.exit() # wavファイルの最後まで行って窓関数かけられなくなったら終了

    # ここにPyGame絡みの終了条件(ここでは省略)

# --------------------------------------------------------------------
# ~後略~
```

やっていること自体はシンプルで、`N`個分のデータをサンプリングして窓関数をかけてFFTを行い、振幅スペクトルを計算したら、サンプリング対象を`SHIFT`分だけずらして次の呼び出しに備えます。あとは、計算した振幅スペクトルを**PyGame**を使って描画するだけです。


##3-4. PyGameを用いた描画

こちらの記事([Pythonでビジュアライザ 初心者向け](http://floclo.blog20.fc2.com/blog-entry-69.html))を参考にいじっていきます。

```python:一部抜粋
import pygame
from pygame.locals import *
# --------------------------------------------------------------------
# パラメータ
# --------------------
# ~中略~
# --------------------
# 描画用
SCREEN_SIZE = (854, 480)  # ディスプレイのサイズ
rectangle_list = []

# --------------------
# pygame画面初期設定
pygame.init()
screen = pygame.display.set_mode(SCREEN_SIZE)
pygame.display.set_caption("Pygame Audio Visualizer")
# --------------------------------------------------------------------
# 「FFTかけて描画」を繰り返す．
def redraw():
    # ~ 中略 ~
    global screen
    global rectangle_list

    # --------------------
    # 対象サンプル点のブロックにFFTをかけて振幅スペクトル(amplitudeSpectrum)を計算する処理(省略)
    # --------------------
    # Pygameでの描画

    screen.fill((240, 128, 128))  # 好きな色で初期化する
    rectangle_list.clear()  # 四角形リスト初期化
    # スペクトル描画 数値は実行して調整しながら
    for i in range(86):
        rectangle_list.append(pygame.draw.line(screen, (102, 205, 170), (1+i * 10, 350 + amplitudeSpectrum[i] * 1),
                                               (1+i * 10, 350 - amplitudeSpectrum[i] * 1), 4))

    pygame.display.update(rectangle_list)  # ディスプレイ更新

    # ~中略~

    for event in pygame.event.get():  # 終了処理
        if event.type == QUIT:
            sys.exit()
        if event.type == KEYDOWN:
            if event.key == K_ESCAPE:
                sys.exit()
# --------------------------------------------------------------------
# ~後略~
```

どうやって波を表示するかが問題ですが、例えば`pygame.draw.line`を使えば、ヒストグラムのような要領で、波を複数の直線で表現できそうです。ここら辺はいくらでもアレンジが効くと思います。PyGameのメソッドは[ここ](http://westplain.sakuraweb.com/translate/pygame/Display.cgi)にまとまっています。`pygame.draw.line`はこんな感じで使うみたいですね。

> pygame.draw.line
直線の線分を描写します。
>> pygame.draw.line(Surface, color, start_pos, end_pos, width=1): return Rect
Surface上に直線の線分を描写します。線の両端に特別な装飾はなく、線の太さに合わせた四角い形となります。

描画の流れの例としては、あらかじめPyGameウィンドウのサイズを決めて、初期化しておき、

1. 背景色を決め、PyGameの画面を初期化する(色はこのようなサイトで見れます/[WEBカラー見本一覧](https://note.cman.jp/color/base_color.cgi))
2. 計算した振幅スペクトルをもとに直線のオブジェクト(`pygame.Rect`)を作ってリストに保持
3. `pygame.display.update()`で画面更新(=描画)

という感じでしょうか。PyGameのウィンドウが×ボタンで消されたり、escキーが押されたりした場合の終了処理も用意しておきましょう。

(ちなみに、ディスプレイサイズを854*480にしているのはyoutubeの縦横比に合わせるためで、forループのレンジを86にしているのは今回作った波を表す四角形(直線)の間隔だと87個目以降は画面からはみ出てしまうからです。ここらへんの記述はあまりスマートじゃないですね…すみません。
適当に数字を変えながら遊んでみたら、挙動もなんとなく掴めるのではないかと思います。)


#4. その他

サンプルコードでは背景色に波が動いているだけですが、他にも冒頭gifのようにキャラやロゴの画像とかも載せることができます。(参考：[Python3でPygame入門：チャプター1](https://techracho.bpsinc.jp/katayama-yuuki/2017_10_17/45135))`redraw()`内で`Surface.blit()`すれば簡単に実装できると思います。

また、今回はここまでで力尽きて、できた画面を画面収録することで動画化しましたが、PyGameの画面を動画に書き出す、なんてことをされている方もいるようです。[【PyGame】画面のAVI書き出し＆スクリーンショット](http://boxheadroom.com/2009/02/20/pygame_avi_save)

#5. 感想

Pythonは大学の授業でサンプルコードをいじる程度にしか触ったことがなかったのですが、色々便利なライブラリがあって面白いですね。お作法がなってないところも多々あったかもしれませんが、ちょっとずつ勉強していけたらと思います。

お疲れ様でした！