Add minimum-phase filter, issue #9

pull/14/head
Ewald de Wit 2022-09-23 14:41:29 +02:00
rodzic 6650420b9c
commit ef0f1cea7e
3 zmienionych plików z 66 dodań i 19 usunięć

Wyświetl plik

@ -1,6 +1,6 @@
"""'Optimize the frequency response spectrum of an audio system""" """'Optimize the frequency response spectrum of an audio system"""
from hifiscan.analyzer import ( from hifiscan.analyzer import (
Analyzer, XY, geom_chirp, linear_chirp, resample, smooth, taper, Analyzer, XY, geom_chirp, linear_chirp, minimum_phase, resample,
tone, window) smooth, taper, tone, window)
from hifiscan.audio import Audio, read_correction, write_wav from hifiscan.audio import Audio, read_correction, write_wav

Wyświetl plik

@ -3,9 +3,9 @@ import types
from functools import lru_cache from functools import lru_cache
from typing import List, NamedTuple, Optional, Tuple from typing import List, NamedTuple, Optional, Tuple
from numba import njit
import numpy as np import numpy as np
from numba import njit
from numpy.fft import fft, ifft, irfft, rfft
class XY(NamedTuple): class XY(NamedTuple):
@ -103,9 +103,9 @@ class Analyzer:
sz = len(recording) sz = len(recording)
self.time = sz / self.rate self.time = sz / self.rate
if sz >= self.x.size: if sz >= self.x.size:
Y = np.fft.fft(recording) Y = fft(recording)
X = np.fft.fft(np.flip(self.x), n=sz) X = fft(np.flip(self.x), n=sz)
corr = np.fft.ifft(X * Y).real corr = ifft(X * Y).real
idx = int(corr.argmax()) - self.x.size + 1 idx = int(corr.argmax()) - self.x.size + 1
if idx >= 0: if idx >= 0:
self.y = np.array(recording[idx:idx + self.x.size]) self.y = np.array(recording[idx:idx + self.x.size])
@ -159,10 +159,10 @@ class Analyzer:
return interp return interp
def X(self) -> np.ndarray: def X(self) -> np.ndarray:
return np.fft.rfft(self.x) return rfft(self.x)
def Y(self) -> np.ndarray: def Y(self) -> np.ndarray:
return np.fft.rfft(self.y) return rfft(self.y)
def calcH(self) -> np.ndarray: def calcH(self) -> np.ndarray:
""" """
@ -171,7 +171,7 @@ class Analyzer:
X = self.X() X = self.X()
Y = self.Y() Y = self.Y()
# H = Y / X # H = Y / X
H = Y * np.conj(X) / (np.abs(X) ** 2 + 1e-3) H = Y * np.conj(X) / (np.abs(X) ** 2 + 1e-6)
if self._calibration: if self._calibration:
H *= 10 ** (-self.calibration() / 20) H *= 10 ** (-self.calibration() / 20)
H = np.abs(H) H = np.abs(H)
@ -199,7 +199,7 @@ class Analyzer:
def h(self) -> XY: def h(self) -> XY:
"""Calculate impulse response ``h`` in the time domain.""" """Calculate impulse response ``h`` in the time domain."""
_, H = self.H() _, H = self.H()
h = np.fft.irfft(H) h = irfft(H)
h = np.hstack([h[h.size // 2:], h[0:h.size // 2]]) h = np.hstack([h[h.size // 2:], h[0:h.size // 2]])
t = np.linspace(0, h.size / self.rate, h.size) t = np.linspace(0, h.size / self.rate, h.size)
return XY(t, h) return XY(t, h)
@ -223,7 +223,8 @@ class Analyzer:
secs: float = 0.05, secs: float = 0.05,
dbRange: float = 24, dbRange: float = 24,
kaiserBeta: float = 5, kaiserBeta: float = 5,
smoothing: float = 0) -> XY: smoothing: float = 0,
minPhase: bool = False) -> XY:
""" """
Calculate the inverse impulse response. Calculate the inverse impulse response.
@ -232,6 +233,7 @@ class Analyzer:
dbRange: Maximum attenuation in dB (power). dbRange: Maximum attenuation in dB (power).
kaiserBeta: Shape parameter of the Kaiser tapering window. kaiserBeta: Shape parameter of the Kaiser tapering window.
smoothing: Strength of frequency-dependent smoothing. smoothing: Strength of frequency-dependent smoothing.
minPhase: Use minimal-phase if True or linear-phase if False
""" """
freq, H2 = self.H2(smoothing) freq, H2 = self.H2(smoothing)
# Apply target curve. # Apply target curve.
@ -239,6 +241,9 @@ class Analyzer:
H2 = H2 * 10 ** (-self.target() / 10) H2 = H2 * 10 ** (-self.target() / 10)
# Re-sample to halve the number of samples needed. # Re-sample to halve the number of samples needed.
n = int(secs * self.rate / 2) n = int(secs * self.rate / 2)
if minPhase:
# Later minimum phase filter will halve the size.
n *= 2
H = resample(H2, n) ** 0.5 H = resample(H2, n) ** 0.5
# Accommodate the given dbRange from the top. # Accommodate the given dbRange from the top.
H /= H.max() H /= H.max()
@ -251,14 +256,16 @@ class Analyzer:
Z = Z * phase Z = Z * phase
# Calculate the inverse impulse response z. # Calculate the inverse impulse response z.
z = np.fft.irfft(Z) z = irfft(Z)
z = z[:-1] z = z[:-1]
z *= window(z.size, kaiserBeta) z *= window(z.size, kaiserBeta)
if minPhase:
z = minimum_phase(z)
# Normalize using a fractal dimension for scaling. # Normalize using a fractal dimension for scaling.
dim = 1.5 dim = 1.25 if minPhase else 1.5
norm = (np.abs(z) ** dim).sum() ** (1 / dim) norm = (np.abs(z) ** dim).sum() ** (1 / dim)
z /= norm z /= norm
# assert np.allclose(z[-(z.size // 2):][::-1], z[:z.size // 2])
t = np.linspace(0, z.size / self.rate, z.size) t = np.linspace(0, z.size / self.rate, z.size)
return XY(t, z) return XY(t, z)
@ -268,7 +275,7 @@ class Analyzer:
Calculate correction factor for each frequency, given the Calculate correction factor for each frequency, given the
inverse impulse response. inverse impulse response.
""" """
Z = np.abs(np.fft.rfft(invResp)) Z = np.abs(rfft(invResp))
Z /= Z.max() Z /= Z.max()
freq = np.linspace(0, self.rate / 2, Z.size) freq = np.linspace(0, self.rate / 2, Z.size)
return XY(freq, Z) return XY(freq, Z)
@ -388,3 +395,33 @@ def taper(y0: float, y1: float, size: int) -> np.ndarray:
"""Create a smooth transition from y0 to y1 of given size.""" """Create a smooth transition from y0 to y1 of given size."""
tp = (y0 + y1 - (y1 - y0) * np.cos(np.linspace(0, np.pi, size))) / 2 tp = (y0 + y1 - (y1 - y0) * np.cos(np.linspace(0, np.pi, size))) / 2
return tp return tp
def minimum_phase(x: np.ndarray) -> np.ndarray:
"""
Homomorphic filter to create a minimum-phase impulse from the given
symmetric odd-sized linear-phase impulse.
https://www.rle.mit.edu/dspg/documents/AVOHomoorphic75.pdf
https://www.katjaas.nl/minimumphase/minimumphase.html
"""
mid = x.size // 2
if not (x.size % 2 and np.allclose(x[:mid], x[-1:mid:-1])):
raise ValueError('Symmetric odd-sized array required')
# Go to frequency domain, oversampling 4x to avoid aliasing.
X = np.abs(fft(x, 4 * x.size))
# Non-linear mapping.
XX = np.log(np.fmax(X, 1e-9))
# Linear filter selects minimum phase part in the complex cepstrum.
xx = ifft(XX).real
yy = np.zeros_like(xx)
yy[0] = xx[0]
yy[1:mid + 1] = 2 * xx[1:mid + 1]
YY = fft(yy)
# Non-linear mapping back.
Y = np.exp(YY)
# Go back to time domain.
y = ifft(Y).real
# Take the valid part.
y_min = y[:mid + 1]
return y_min

Wyświetl plik

@ -100,8 +100,9 @@ class App(qt.QMainWindow):
dbRange = self.dbRange.value() dbRange = self.dbRange.value()
beta = self.kaiserBeta.value() beta = self.kaiserBeta.value()
smoothing = self.irSmoothing.value() smoothing = self.irSmoothing.value()
minPhase = self.typeBox.currentIndex() == 1
t, ir = analyzer.h_inv(secs, dbRange, beta, smoothing) t, ir = analyzer.h_inv(secs, dbRange, beta, smoothing, minPhase)
self.irPlot.setData(1000 * t, ir) self.irPlot.setData(1000 * t, ir)
logIr = np.log10(1e-8 + np.abs(ir)) logIr = np.log10(1e-8 + np.abs(ir))
@ -139,9 +140,11 @@ class App(qt.QMainWindow):
db = int(self.dbRange.value()) db = int(self.dbRange.value())
beta = int(self.kaiserBeta.value()) beta = int(self.kaiserBeta.value())
smoothing = int(self.irSmoothing.value()) smoothing = int(self.irSmoothing.value())
_, irInv = analyzer.h_inv(ms / 1000, db, beta, smoothing) minPhase = self.typeBox.currentIndex() == 1
_, irInv = analyzer.h_inv(ms / 1000, db, beta, smoothing, minPhase)
name = f'IR_{ms}ms_{db}dB_{beta}t_{smoothing}s.wav' name = (f'IR_{ms}ms_{db}dB_{beta}t_{smoothing}s'
f'{"_minphase" if minPhase else ""}.wav')
filename, _ = qt.QFileDialog.getSaveFileName( filename, _ = qt.QFileDialog.getSaveFileName(
self, 'Save inverse impulse response', self, 'Save inverse impulse response',
str(self.saveDir / name), 'WAV (*.wav)') str(self.saveDir / name), 'WAV (*.wav)')
@ -276,6 +279,10 @@ class App(qt.QMainWindow):
self.useBox.addItems(['Stored measurements', 'Last measurement']) self.useBox.addItems(['Stored measurements', 'Last measurement'])
self.useBox.currentIndexChanged.connect(self.plot) self.useBox.currentIndexChanged.connect(self.plot)
self.typeBox = qt.QComboBox()
self.typeBox.addItems(['Zero phase', 'Zero latency'])
self.typeBox.currentIndexChanged.connect(self.plot)
exportButton = qt.QPushButton('Export as WAV') exportButton = qt.QPushButton('Export as WAV')
exportButton.setShortcut('E') exportButton.setShortcut('E')
exportButton.setToolTip('<Key E>') exportButton.setToolTip('<Key E>')
@ -295,6 +302,9 @@ class App(qt.QMainWindow):
hbox.addWidget(qt.QLabel('Smoothing: ')) hbox.addWidget(qt.QLabel('Smoothing: '))
hbox.addWidget(self.irSmoothing) hbox.addWidget(self.irSmoothing)
hbox.addSpacing(32) hbox.addSpacing(32)
hbox.addWidget(qt.QLabel('Type: '))
hbox.addWidget(self.typeBox)
hbox.addSpacing(32)
hbox.addWidget(qt.QLabel('Use: ')) hbox.addWidget(qt.QLabel('Use: '))
hbox.addWidget(self.useBox) hbox.addWidget(self.useBox)
hbox.addStretch(1) hbox.addStretch(1)