🎵

WAVファイルの音を解析する (2)

に公開

その1の続きです。

https://zenn.dev/npwshy/articles/b90937906d25ee

その1ではメインとなる起動スクリプトを見てきましたので、それ以外を。

ByteBufferクラス

WAVファイルのバイナリデータを格納する Byte[] と読み出し場所を管理する Index、ファイル読み込み関数 Load()、バイト列からデータ形式に従ってデータを取ってくる Getなんとか() 関数群で構成しています。こういうの標準にありそうなものなのですが見つけられず自作。

ByteBuffer.psm1
class ByteBuffer {
    [byte[]] $Bytes;
    $Index;

    Load([string]$file) {
        $this.Bytes = [IO.File]::ReadAllBytes($file)
        $this.Index = 0
        log "$($this.GetType().Name).Load: File loaded: $($file) Size=$($this.Bytes.Length)"
    }

...
    [int16] GetInt16() {
        $s = $this.Index
        $this.Index += 2;
        return [BitConverter]::ToInt16($this.Bytes, $s)
    }

...

    [single] GetSingle() {
        $s = $this.Index
        $this.Index += 4;
        return [BitConverter]::ToSingle($this.Bytes, $s)
    }

...

}

BufferReader クラス

ByteBufferクラスに用意した GetgInt16()、GetSingle() で用は足りるのですが、例えば 44.1K でサンプリングされている一般的な音データ3分で 800万回近く実行されるとなると関数呼び出しやインデックス管理のオーバヘッドもバカにできない? ということで専用にクラスを用意します。BufferReader はそのベースになるクラスとして ByteBuffer クラスを継承して作ります。実際にはさらにこのクラスから派生させた 32bit Float用、16bit用のクラスを使うのでここではコンストラクターだけ。

BufferReader.psm1
class BufferReader : ByteBuffer {
    $BlockSize;
    $SkipSize;

    BufferReader([ByteBuffer]$b, $bs) {
        $this.Bytes = $b.Bytes
        $this.Index = $b.Index
        $this.BlockSize = $bs
    }
}

Float32、PCM16 クラス

WAVファイルは複数チャネルの音データを格納することができますし、実際にそうなっていますが、必要なのは1チャネル分です。データは時系列順に全チャネルがまとまっているので必要なデータは飛び飛びに置かれている状況です。その飛び飛びの状況を SkipSize で覚えておきます、が、高速化のために一回の関数呼び出しで一回分のFFTデータを全部コピーする CopyData() を導入して不要になってしまいました。

データを取ってきてコピーする CopyData() では指定の場所に指定の個数分データを複素数化して書き込んでいきます。前述のように必要なデータは飛び飛びになっているので、都度 BlockSize 分読み込み位置をずらします。

Float32
class Float32 : BufferReader {
    Float32([ByteBuffer]$b, $bs) : base($b, $bs) {
        $this.SkipSize = $bs - 4
    }

...

    CopyData($buff, $bIndex, $count) {
        1 .. $count |%{
            $v = [BitConverter]::ToSingle($this.Bytes, $this.Index)
            $this.Index += $this.BlockSize
            $buff[$bIndex++] = NewComplex $v
        }
    }
}

PCM16でもやることは変わらず。

PCM16
class PCM16 : BufferReader {
    PCM16($b, $bs) : base($b, $bs) {
        $this.SkipSize = $bs - 2
    }

...

    CopyData($buff, $bIndex, $count) {
        1 .. $count | % {
            $v = [BitConverter]::ToInt16($this.Bytes, $this.Index)
            $this.Index += $this.BlockSize
            $buff[$bIndex++] = NewComplex $v
        }
    }
}

次のデータへスキップする量、データの取得が ToSingle() か ToInt16() なのかという違いだけです。

WaveFile.psm1

WAVファイルを読み込むモジュールです。

WaveFileクラスがその中心で、ファイルのバイナリデータ、Fmtチャンク、Dataチャンクを保持します。他にいろいろな形式のチャンクがあるようですが、実際に Bext チャンクも解析してみたりもしていますが、必要なのは Fmt と Data の2つということで他は捨てます。

やっていることは単純でファイルフォーマットの定義に従って共通のヘッダを読み込み、その後はヘッダ先頭のID文字列に従ってそれぞれのチャンクを読み込んでいきます。

WaveFile.psm1 WaveFile
class WaveFile {
    [ByteBuffer] $FileBuffer;
    $FmtChunk;
    $DataChunk;

    Load([string]$file) {
        $this.FileBuffer = [ByteBuffer]::New()
        $this.FileBuffer.Load($file)

        $header = [WaveHeader]::New()
        $header.ReadFromBuffer($this.FileBuffer)

        while ($ckId = $this.FileBuffer.GetStringN(4)) {
            switch ($ckId) {
                'bext' {
                    [void][BExtChunk]::New($ckId, $this.FileBuffer)
                    break
                }
                'data' {
                    $this.DataChunk = [DataChunk]::New($ckId, $this.FileBuffer)
                    $this.DataChunk.Show()
                    return;
                }
                'fmt ' {
                    $this.FmtChunk = [FmtChunk]::New($ckId, $this.FileBuffer)
                    $this.FmtChunk.Show()
                    break
                }
                default {
                    [void][JunkChunk]::New($ckId, $this.FileBuffer)
                }
            }
        }
    }
}

FmtChunkクラス

チャンクの読み込みは特に変わったこともしていないので代表で Fmt ヘッダーを担当する FmtChunkクラスを見ていきましょう。

とはいえ、ヘッダに入っているデータ用に変数を用意し、仕様に従ってデータを順次取り出してきているだけの簡単なお仕事。

WaveFile.psm1 FmtChunk
class FmtChunk : Chunk {
    $FormatTag;
    $Channels;
    $SamplesPerSec;
    $AvgBytesPerSec;
    $BlockAlign;
    $BitsPerSample;
    $ExtensionSize;
    $ValidBitsPerSample;
    $ChannelMask;
    $SubFormat;

    FmtChunk($id, [ByteBuffer]$b) : base($id, $b) {
        $this.FormatTag = $b.GetUInt16()
        $this.Channels = $b.GetInt16()
        $this.SamplesPerSec = $b.GetUInt32()
        $this.AvgBytesPerSec = $b.GetUInt32()
        $this.BlockAlign = $b.GetInt16()
        $this.BitsPerSample = $b.GetInt16()
        if ($this.ChunkSize -gt 16) {
            $this.ExtensionSize = $b.GetInt16()
            if ($this.ExtensionSize) {
                $this.ValidBitsPerSample = $b.GetInt16()
                $this.ChannelMask = $b.GetInt32()
                $this.SubFormat = $b.GetStringN(16)
            }
        }
    }
}

JunkChunk クラス

実際に junk とチャンクIDに入っているデータもあるのですが、なぜジャンク?

ともあれ、Fmt、Dataチャンク以外はJunkチャンク扱いで捨てます。すべてのチャンクはIDとチャンクサイズが共通で入っているのでチャンクサイズ分 Index を進めて読み飛ばします。

WaveFile.psm1 JunkChunk
class JunkChunk : Chunk {
    JunkChunk($id, [ByteBuffer]$b) : base($id, $b) {
        $nextChunk = $b.GetCurrentPosition() + $this.ChunkSize
        $b.Seek($nextChunk)
    }
}

Pitch.psm1

この Pitch クラスではFFT解析結果を解析して抽出した周波数がどの音なのかを対応付ける処理をします。

440Hz を見つけたら、「ラ」の音とするって部分ですね。また、441Hz も 439Hz も「ラ」と認定するわけですが、対応する音の正規の周波数とか、その周波数からどのくらいずれているかということも処理の過程で分かりますし、このあとの「音の良し悪しの判定」でも利用しますので残しておきます。

この残した値は関数の戻り値で返すのが一般的ですが、返す値が複数個なのでオブジェクトを作って返すことになります。今回はそのオブジェクトも一回参照されたら後は使われないので、速度優先でインスタンスのプロパティを変更し呼び出した側から参照してもらうことにします。次回の呼び出しで値は変わってしまうわけですが、それまでに前の音の処理は終わっているはずなので問題ないでしょう。

呼び出し側で全部の音の詳細を保持するような利用ケースがでたらオブジェクトを返すように変えましょうか。

ということでコードです。

音の名前(Name)、周波数(Freq)、正規の周波数からの誤差(FreqDiff)、誤差の絶対値(FreqDiffAbs)
、正規の周波数(RightFreq)などのプロパティを用意しておきます。

また、Notes配列に「正規な音」のデータを仕込んでおきます。「正規な音」の周波数がいくつであるべきかはいろいろな考え方があるということなので、ここではネットでひろってきたそれっぽいものを使っています。基準となる A ラの音は 442。この情報は JSON ファイルで外だしにして切り替えられるようにしたり、オプション指定から動的に決めてもいいかもですね。

Pitch.psm1

class Pitch {
    $Name;
    $Freq;
    $FreqDiff;
    $FreqDiffAbs;
    $RightFreq;

    $Notes = @(
        @{ Name = "!!Too Low!!"; Freq = 100; },
        @{ Name = "G3 ソ"; Freq = 197; },
        @{ Name = "G#3 ソ#"; Freq = 208; },
        @{ Name = "A3 ラ"; Freq = 221; },
        @{ Name = "A#3 ラ#"; Freq = 234; },
        @{ Name = "B3 シ"; Freq = 248; },
        @{ Name = "C4 ド"; Freq = 264; },
        @{ Name = "C#4 ド#"; Freq = 279; },
        @{ Name = "D4 レ"; Freq = 297; },
        @{ Name = "D#4 レ#"; Freq = 313; },
...

実際にやっていることはそう難しくもなく、音の定義テーブルは周波数順に並べていますから、調べたい音の周波数の前後にくるものを探し出して、より近いほうを「出ている音」として選択します。

Pitch.psm1 Find()
    Find($f) {
        $this.Freq = $f
        foreach ($i in 2 .. $($this.Notes.Count - 1)) {
            if ($f -gt $this.Notes[$i].Freq) { continue }
            $lo = $this.Notes[$i - 1]
            $hi = $this.Notes[$i]
            $dlo = $f - $lo.Freq
            $dloa = [Math]::Abs($dlo)
            $dhi = $f - $hi.Freq
            $dhia = [Math]::Abs($dhi)
            if ($dloa -lt $dhia) {
                $this.Name = $lo.Name
                $this.RightFreq = $lo.Freq
                $this.FreqDiff = $dlo
                $this.FreqDiffAbs = $dloa
            }
            else {
                $this.Name = $hi.Name
                $this.RightFreq = $hi.Freq
                $this.FreqDiff = $dhi
                $this.FreqDiffAbs = $dhia
            }
            return
        }
    }

あと残っているのは ResultManager クラスなのですが、これはこれまでの処理結果を表示するだけのコードになっているので振り返らなくてもいいかな。

この時点のバージョンでは全体でどれだけずれていたか、上にずれたのか下にずれたのかだけを表示するようになっていますが、将来的にはそれぞれの音でのずれ具合を集計して出すようにしようと思っています。ファは良いけど、ドはずれ易い、とかがわかるように。


ネットにはFFTについての詳細やFFTライブラリの使い方など様々な情報があり、FFT無学者の私でもこのような(見苦しいとはいえ)それなりに動作するコードが創れたのものそういった情報を提供してくださった方のおかげと感謝しております。

とはいえ、FFT結果の周波数データをどう処理してくかについてはあまり情報もなく、無いなら作ってみるか、で始めましたのでこうやって情報提供するのも多少は意味があることだと思っています。

出ていないはずの音を表示することもあるので、ノイズ除去のロジックと閾値が甘いとか、FFT処理が雑とか改善点はこの時点でもあるのですが、いったん取りまとめておくことにします。

ここまで読んでいただいてありがとうございます。

Discussion