何かあれば GitHub のリポジトリに issue を作るか ryukau@gmail.com までお気軽にどうぞ。
Update: 2024-08-06
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 で実装します。 sos
は
scipy.signal
で使われる 2 次セクション (second order
sections) の形式のフィルタ係数です。
import numpy as np
= 48000
sampleRate = 10000
cutoffHz
= 1 / np.tan(np.pi * cutoffHz / sampleRate)
k = 1 + k
a0 = (1 - k) / a0
a1 = 1 / a0
b = [[b, b, 0, 1, a1, 0]] sos
以下は周波数特性のプロットです。ゲイン特性とカットオフ周波数が -3 dB で交差していることが確認できました。厳密な交差点のゲインは \(20 \log_{10} (1/\sqrt{2}) \approx -3.0103\ \text{dB}\) です。
ナイキスト周波数のゲインは \(z=-1\) を \(H(z)\) に代入すると得られます。 \(H(z)\) は分母が \(1 + z^{-1}\) なので理論上のゲインは 0 です。
実際に計算すると理論通りに動かないことがあるので以下のコードで確認しました。
x
が入力、 y
が出力です
= np.ones(2**16)
x 1::2] = -1
x[= signal.sosfilt(sos, x) y
以下は y
のプロットです。 0 に収束しています。
EMA フィルタよりは性能がいいことが見て取れます。
インパルス応答のような波形になっているのは、何もないところからサイン波がいきなり立ち上がったからです。
x
が緩やかにフェードインするように窓関数をかけて再度試しました。
= np.ones(2**16)
x 1::2] = -1
x[*= signal.get_window("hann", len(x)) # 追加。
x = signal.sosfilt(sos, x) y
以下は y
のプロットです。図を拡大すると振幅は小さいものの完全には 0
にならないことが分かります。それでも EMA
より良い性能を維持しています。
C++ 20 で実装します。
#include <cmath>
#include <numbers>
// BLT: Bilinear transform.
template<typename Sample> class BLTLP1 {
private:
= 1;
Sample bn = -1; // Negated.
Sample a1 = 0;
Sample x1 = 0;
Sample y1
public:
void reset()
{
= 0;
x1 = 0;
y1 }
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;
= Sample(1) / a0;
bn = (k - Sample(1)) / a0; // Negated.
a1 }
(Sample x0)
Sample process{
auto y0 = bn * (x0 + x1) + a1 * y1;
= x0;
x1 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 で実装します。
= 1 / np.tan(np.pi * cutoffHz / sampleRate)
k = 1 + k
a0 = k / a0
b0 = -b0
b1 = (1 - k) / a0
a1 = [[b0, b1, 0, 1, a1, 0]] sos
以下はバイリニア変換による 1 次ハイパスの周波数応答です。
C++ で実装します。
template<typename Sample> class BLTHP1 {
private:
= 1;
Sample b0 = 1;
Sample a1 = 0;
Sample x1 = 0;
Sample y1
public:
void reset()
{
= 0;
x1 = 0;
y1 }
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;
= k / a0;
b0 = (Sample(1) - k) / a0;
a1 }
(Sample x0)
Sample process{
auto y0 = b0 * (x0 - x1) - a1 * y1;
= x0;
x1 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):
= 1 - np.cos(2 * np.pi * cutoffHz / sampleRate)
y = np.sqrt(y * y + 2 * y) - y
kp 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):
= 1 - np.cos(2 * np.pi * cutoffHz / sampleRate)
y = np.sqrt(y * y + 2 * y) - y
kp = 1 - kp
kq return [[kq, -kq, 0, 1, -kq, 0]]