Wizard Notes

Python, JavaScript を使った音楽信号分析の技術録、作曲活動に関する雑記

Python: waveモジュールをハックしてfloat形式のwavファイルを読み込みnumpy配列に変換する

問題

www.wizard-notes.com

上記の実装の拡張として、Python の waveモジュールを使って32-bit or 64-bit float形式のwavファイル読込を追加しようとして試してみたところ、以下のようなエラーがでました。

>wave.Error: unknown format: 3

てっきりwaveモジュールはfloat形式にも対応しているかと思いきや、そうではないようです。

CPythonのWaveモジュールのソースコードを確認してみました。

github.com

87行目には、

WAVE_FORMAT_PCM = 0x0001

のみで、WAVE_FORMAT_IEEE_FLOATなどはありません。

また、254-273行目を見てもPCM以外の形式はunknown formatとしてエラー扱いになっていることが分かります。。

   def _read_fmt_chunk(self, chunk):
        try:
            wFormatTag, self._nchannels, self._framerate, dwAvgBytesPerSec, wBlockAlign = struct.unpack_from('<HHLLH', chunk.read(14))
        except struct.error:
            raise EOFError from None
        if wFormatTag == WAVE_FORMAT_PCM:
            try:
                sampwidth = struct.unpack_from('<H', chunk.read(2))[0]
            except struct.error:
                raise EOFError from None
            self._sampwidth = (sampwidth + 7) // 8
            if not self._sampwidth:
                raise Error('bad sample width')
        else:
            raise Error('unknown format: %r' % (wFormatTag,))
        if not self._nchannels:
            raise Error('bad # of channels')
        self._framesize = self._nchannels * self._sampwidth
        self._comptype = 'NONE'
        self._compname = 'not compressed'

ということで、Python (CPython) の wave モジュールではFloat形式のWAVファイルは読み込めません

解決方法

4つ考えられます。

  1. PySoundFilescipy.io.wavfile.readなどの非標準モジュールを使う
  2. 自前でWavファイルのバイナリをパースするモジュールを実装する
  3. 読み込むファイルを別アプリでPCM形式に変換しておく
  4. wave モジュールをハックする

1 は、楽ですが非標準モジュールを使う必要があります。また、2 は実装・検証がめんどうですし、3をいちいちやるのはもっと面倒です。

そこで、この中で一番効率がよさそうな 4 をやってみました。

waveモジュールのWav_readクラスをハック

wave_open()を見てみましょう。

def open(f, mode=None):
    if mode is None:
        if hasattr(f, 'mode'):
            mode = f.mode
        else:
            mode = 'rb'
    if mode in ('r', 'rb'):
        return Wave_read(f)
    elif mode in ('w', 'wb'):
        return Wave_write(f)
    else:
        raise Error("mode must be 'r', 'rb', 'w', or 'wb'")

WAVファイル読込の場合、wave.Wave_readクラスを呼び出しているだけです。

また、先ほどの_read_fmt_chunk()関数はwave.Wave_readクラスのメソッドです。

従って、以下のようにwave.Wave_read._read_fmt_chunk()をオーバーライドすることでハックできます。

具体的には、Wav_readを継承したクラスにWAVE_FORMAT_IEEE_FLOAT'unknown formatraiseを避けるようにしています。

また、後段でintfloatを区別できるようにするために、getformatid関数をWav_readを継承したクラスに追加します。

import wave
import numpy as np
import struct
import matplotlib.pyplot as plt

WAVE_FORMAT_PCM        = 0x0001
WAVE_FORMAT_IEEE_FLOAT = 0x0003


class HackedWaveRead(wave.Wave_read):
    def _read_fmt_chunk(self, chunk):
        try:
            wFormatTag, self._nchannels, self._framerate, dwAvgBytesPerSec, wBlockAlign = struct.unpack_from('<HHLLH', chunk.read(14))
        except struct.error:
            raise EOFError from None
        if wFormatTag == WAVE_FORMAT_PCM or wFormatTag == WAVE_FORMAT_IEEE_FLOAT:
            self.wFormatTag = int(wFormatTag)
            try:
                sampwidth = struct.unpack_from('<H', chunk.read(2))[0]
            except struct.error:
                raise EOFError from None
            self._sampwidth = (sampwidth + 7) // 8
            if not self._sampwidth:
                raise Error('bad sample width')
        else:
            raise Error('unknown format: %r' % (wFormatTag,))
        if not self._nchannels:
            raise Error('bad # of channels')
        self._framesize = self._nchannels * self._sampwidth
        self._comptype = 'NONE'
        self._compname = 'not compressed'
        
    def getformatid(self):
        return self.wFormatTag

def load_wav_float(wavfilepath):
    # wavファイルを読み込む
    wf   = HackedWaveRead(wavfilepath)

    # wavファイルのパラメタを取得
    sr        = wf.getframerate() # サンプリング周波数
    n_ch      = wf.getnchannels() # チャンネル数
    n_frames  = wf.getnframes()   # オーディオフレーム数
    n_bytes   = wf.getsampwidth() # 1サンプル数あたりのバイト数
    format_id = wf.getformatid()  # フォーマットID取得(HackedWaveRead()で定義 )
    
    # 信号データを取得
    audiobuffer = wf.readframes(n_frames)
    wf.close()
    
    
    # 1サンプルあたりのバイト数に応じて信号を読込
    norm = 1.0
    if   n_bytes == 4 and format_id == WAVE_FORMAT_PCM :
        data = np.frombuffer(audiobuffer, dtype=np.int32)
        norm = 2**31
        mode = "32-bit int"
    elif n_bytes == 4 and format_id == WAVE_FORMAT_IEEE_FLOAT:
        data = np.frombuffer(audiobuffer, dtype=np.float32)
        mode = "32-bit float"
    elif n_bytes == 8 and format_id == WAVE_FORMAT_IEEE_FLOAT:
        data = np.frombuffer(audiobuffer, dtype=np.float64)
        mode = "64-bit float"
    

    # 各チャネルの信号に分離
    n_samples = data.shape[0] // n_ch
    x = np.zeros((n_ch, n_samples))
    for k in range(n_ch):
        x[k] = data[k::n_ch]

    # [-1, 1) に正規化
    x /= norm
    print(wavfilepath, mode, f"{n_ch}CH", np.min(x), np.max(x))
    return x, sr
 

if __name__ == "__main__":    
    filepath_list = [
        "sin_32bit_int_5ch.wav",
        "sin_32bit_float_5ch.wav", 
        "sin_64bit_float_5ch.wav", 
    ]
    for k, filepath in enumerate(filepath_list):
        x, sr = load_wav_float(filepath)
        
        plt.subplot(len(filepath_list), 1, k+1)
        for c in range(0, x.shape[0]):
            plt.plot(x[c], label=f"{c} ch.")
        plt.ylim(-1.0, 1.0)
        plt.title(filepath)
        plt.legend()
    plt.tight_layout()
    plt.show()

検証

5チャネルのwavファイルを読み込みました。このオーディオファイルは、チャネル順に振幅の幅が(-1.0,1.0)のサイン波を鳴らしています。

コマンドライン実行結果

>sin_32bit_int_5ch.wav 32-bit int 5CH -1.0 0.9921875
>sin_32bit_float_5ch.wav 32-bit float 5CH -1.0 0.9921894073486328
>sin_64bit_float_5ch.wav 64-bit float 5CH -1.0 0.992188572883606

f:id:Kurene:20210417204845p:plain