何かあれば GitHub のリポジトリに issue を作るか ryukau@gmail.com までお気軽にどうぞ。


インデックスに戻る

Update: 2024-08-06

Table of Contents

Exponential moving average (EMA) フィルタではナイキスト周波数の成分が残るので、バイリニア変換した 1 次ローパスフィルタを作ることにしました。

以下は 1 次ローパスの連続系での伝達関数です。

\[ H(s) = \frac{1}{1 + s/\omega_c} \]

以下はバイリニア変換の式です。

\[ s = \frac{2}{T} \frac{1-z^{-1}}{1+z^{-1}} \]

以降では \(T=2\) とします。 \(T=2\) とするとカットオフ周波数を \(f_c / f_s\) と正規化できます。 \(f_c\) はカットオフ周波数、 \(f_s\) はサンプリング周波数です。

バイリニア変換の式を \(H(s)\) に代入します。また、簡略化のために \(k=1/\omega_c\) と置きます。

\[ \begin{aligned} H(z) &= \frac{1}{1 + k (1-z^{-1})/(1+z^{-1})} \\ &= \frac{1 + z^{-1}}{1+z^{-1} + k - k z^{-1}} \\ &= \frac{1 + z^{-1}}{(1 + k) + (1 - k) z^{-1}} \\ \end{aligned} \]

以下はバイリニア変換による周波数歪みを考慮した \(k\) の計算式です。

\[ k = \frac{1}{\tan(\pi f_c / f_s)} \]

Python 3 で実装します。 sosscipy.signal で使われる 2 次セクション (second order sections) の形式のフィルタ係数です。

import numpy as np

sampleRate = 48000
cutoffHz = 10000

k = 1 / np.tan(np.pi * cutoffHz / sampleRate)
a0 = 1 + k
a1 = (1 - k) / a0
b = 1 / a0
sos = [[b, b, 0, 1, a1, 0]]

以下は周波数特性のプロットです。ゲイン特性とカットオフ周波数が -3 dB で交差していることが確認できました。厳密な交差点のゲインは \(20 \log_{10} (1/\sqrt{2}) \approx -3.0103\ \text{dB}\) です。

Plot of frequency response of bilinear transformed 1-pole lowpass filter.

ナイキスト周波数のゲインは \(z=-1\)\(H(z)\) に代入すると得られます。 \(H(z)\) は分母が \(1 + z^{-1}\) なので理論上のゲインは 0 です。

実際に計算すると理論通りに動かないことがあるので以下のコードで確認しました。 x が入力、 y が出力です

x = np.ones(2**16)
x[1::2] = -1
y = signal.sosfilt(sos, x)

以下は y のプロットです。 0 に収束しています。

Plot of time domain response of nyquist frequency signal. Bilinear reponse is decaying almost immediately. EMA response follows same trend, but oscillation of input signal isn't attenuated as much as bilinear one.

EMA フィルタよりは性能がいいことが見て取れます。

インパルス応答のような波形になっているのは、何もないところからサイン波がいきなり立ち上がったからです。 x が緩やかにフェードインするように窓関数をかけて再度試しました。

x = np.ones(2**16)
x[1::2] = -1
x *= signal.get_window("hann", len(x))  # 追加。
y = signal.sosfilt(sos, x)

以下は y のプロットです。図を拡大すると振幅は小さいものの完全には 0 にならないことが分かります。それでも EMA より良い性能を維持しています。

Plot of time domain response of windowed nyquist frequency signal.

C++ 20 で実装します。

#include <cmath>
#include <numbers>

// BLT: Bilinear transform.
template<typename Sample> class BLTLP1 {
private:
  Sample bn = 1;
  Sample a1 = -1; // Negated.
  Sample x1 = 0;
  Sample y1 = 0;

public:
  void reset()
  {
    x1 = 0;
    y1 = 0;
  }

  void setCutoff(Sample sampleRate, Sample cutoffHz)
  {
    constexpr auto pi = std::numbers::pi_v<Sample>;
    auto k = Sample(1) / std::tan(pi * cutoffHz / sampleRate);
    auto a0 = Sample(1) + k;
    bn = Sample(1) / a0;
    a1 = (k - Sample(1)) / a0; // Negated.
  }

  Sample process(Sample x0)
  {
    auto y0 = bn * (x0 + x1) + a1 * y1;
    x1 = x0;
    return y1 = y0;
  }
};

以下は 1 次ハイパスの連続系での伝達関数です。

\[ H(s) = \frac{s/\omega_c}{1 + s/\omega_c} = \frac{k s}{1 + k s} \]

以下は簡略化したバイリニア変換の式です。

\[ s = \frac{1-z^{-1}}{1+z^{-1}} \]

バイリニア変換の式を \(H(s)\) に代入します。

\[ \begin{aligned} H(z) &= \frac{k \frac{1-z^{-1}}{1+z^{-1}}}{1 + k \frac{1-z^{-1}}{1+z^{-1}}} \\ &= \frac{k - k z^{-1}}{1 + z^{-1} + k - k z^{-1}} \\ &= \frac{k - k z^{-1}}{(1 + k) + (1 - k) z^{-1}} \\ \end{aligned} \]

Python 3 で実装します。

k = 1 / np.tan(np.pi * cutoffHz / sampleRate)
a0 = 1 + k
b0 = k / a0
b1 = -b0
a1 = (1 - k) / a0
sos = [[b0, b1, 0, 1, a1, 0]]

以下はバイリニア変換による 1 次ハイパスの周波数応答です。

Plot of frequency response of bilinear transformed 1-pole highpass filter.

C++ で実装します。

template<typename Sample> class BLTHP1 {
private:
  Sample b0 = 1;
  Sample a1 = 1;
  Sample x1 = 0;
  Sample y1 = 0;

public:
  void reset()
  {
    x1 = 0;
    y1 = 0;
  }

  void setCutoff(Sample sampleRate, Sample cutoffHz)
  {
    constexpr auto pi = std::numbers::pi_v<Sample>;
    auto k = Sample(1) / std::tan(pi * cutoffHz / sampleRate);
    auto a0 = Sample(1) + k;
    b0 = k / a0;
    a1 = (Sample(1) - k) / a0;
  }

  Sample process(Sample x0)
  {
    auto y0 = b0 * (x0 - x1) - a1 * y1;
    x1 = x0;
    return y1 = y0;
  }
};

以下は EMA フィルタの伝達関数です。

\[ \begin{aligned} H(z) &= \frac{k_p}{1 + (k_p - 1) z^{-1}}, \\ k_p &= -y + \sqrt{y^2 + 2 y}, \\ y &= 1 - \cos(2 \pi f_c / f_s). \end{aligned} \]

以下は scipy.signal の sos 形式で EMA フィルタを設計するコードです。

def getEMASos(sampleRate, cutoffHz):
    y = 1 - np.cos(2 * np.pi * cutoffHz / sampleRate)
    kp = np.sqrt(y * y + 2 * y) - y
    return [[kp, 0, 0, 1, kp - 1, 0]]

ハイパスは入力からローパスを減算すれば得られます。

\[ \begin{aligned} H_{\mathrm{HP}}(z) &= 1 - H(z) \\ &= 1 - \frac{k_p}{1 + (k_p - 1) z^{-1}} \\ &= \frac{(1 - k_p) + (k_p - 1) z^{-1}}{1 + (k_p - 1) z^{-1}} \\ \end{aligned} \]

def getEMASos(sampleRate, cutoffHz):
    y = 1 - np.cos(2 * np.pi * cutoffHz / sampleRate)
    kp = np.sqrt(y * y + 2 * y) - y
    kq = 1 - kp
    return [[kq, -kq, 0, 1, -kq, 0]]