Best filter option for hearing loss compensation
Hello,
I created a python script for a hearing test ranging from 100 Hz up to 8 kHz in 12 steps. One ear (the better one) is marked as reference and won't be influenced. The difference values now are written into the CamillaDSP config yaml file. I tried the Biquad filter and the Graphic Equalizer but my impression is that both don't sound as natural as the unchanged channel. What is your Filter recommendation for my purpose?
Thank you.
What are the filters meant to do? Can you show an example? And did you check the filter response in the gui to see that they do what you expect?
There are three types of hearing loss: a drop or loss of low frequencies, drop of high frequencies or the drop of the whole spectrum (or a combination of these).
My Python script is doing what it's supposed to do and writing the filter values into the yaml file. Here is the code:
``import tkinter as tk from tkinter import ttk import numpy as np import sounddevice as sd import threading import matplotlib.pyplot as plt import time import yaml, math
SAMPLERATE = 48000 FREQUENCIES = [100, 250, 500, 750, 1000, 2000, 3000, 4000, 5000, 6000, 7000, 8000] sd.default.device = 'snd_rpi_hifiberry_dacplusadc: - (hw:2,0)' CHANNELS = ["left", "right"] CONFIG_PATH = "/home/pi/camilladsp/configs/hifiberry.yml"
class HoertestApp: def init(self, master): self.master = master self.master.title("Hörtest für Autokorrektur")
self.gain_values = {}
self.start_buttons = {}
self.tone_thread = None
self.stream = None
self.playing_freq = None
self.stop_event = threading.Event()
self.selected_channel = tk.StringVar(value = "left")
self.results = {ch: {} for ch in CHANNELS}
self.build_ui()
def build_ui(self):
ttk.Label(self.master, text = "Kanal:").grid(row = 0, column = 0, sticky = "w", padx = 6)
for i, ch in enumerate(CHANNELS):
ttk.Radiobutton(self.master, text = ch.capitalize(), variable = self.selected_channel, value = ch
).grid(row = 0, column = i + 1, sticky = "w", padx = 6)
for idx, freq in enumerate(FREQUENCIES):
row = 1 + (idx // 6) * 4
col = idx % 6
ttk.Label(self.master, text = f"{freq} Hz").grid(row = row, column = col)
scale = ttk.Scale(self.master, from_ = -15, to = -93, orient = "vertical", length = 250)
scale.set(-60) # Angepasster Startwert für neuen Bereich
scale.grid(row = row + 1, column = col, padx = 5)
self.gain_values[freq] = scale
btn = ttk.Button(self.master, text = "▶ Start", command = lambda f = freq: self.toggle_tone(f))
btn.grid(row=row + 2, column=col, pady=2)
self.start_buttons[freq] = btn
btn_heard = ttk.Button(self.master, text = "✔ Hörbar", command = lambda f = freq: self.save_threshold(f))
btn_heard.grid(row = row + 3, column = col, pady = 2)
self.status_label = ttk.Label(self.master, text = "", foreground = "blue")
self.status_label.grid(row = 10, column = 0, columnspan = 5, pady = 5)
self.master.protocol("WM_DELETE_WINDOW", self.on_close)
def toggle_tone(self, freq):
channel = self.selected_channel.get()
if self.playing_freq == freq:
self.stop_tone()
return
self.stop_tone()
self.playing_freq = freq
self.stop_event.clear()
self.update_button_labels(freq)
def stream_loop():
samplerate = 48000
duration = 0.1
t = np.linspace(0, duration, int(samplerate * duration), endpoint = False)
channel = self.selected_channel.get()
try:
with sd.OutputStream(channels = 2, samplerate = samplerate, dtype = 'float32') as stream:
while not self.stop_event.is_set():
gain_db = self.gain_values[freq].get()
gain_lin = 10 ** (gain_db / 20)
wave = np.sin(2 * np.pi * freq * t) * gain_lin
if channel == "left":
stereo = np.column_stack((wave, np.zeros_like(wave)))
else:
stereo = np.column_stack((np.zeros_like(wave), wave))
stream.write(stereo.astype(np.float32))
self.stream = None
except Exception as e:
print("Fehler im Stream:", e)
self.tone_thread = threading.Thread(target=stream_loop, daemon = True)
self.tone_thread.start()
self.status_label.config(text=f"{freq} Hz läuft ({channel})")
def stop_tone(self):
if self.playing_freq is not None:
self.stop_event.set()
self.update_button_labels(None)
self.playing_freq = None
self.status_label.config(text = "Ton gestoppt")
def update_button_labels(self, active_freq):
for freq, button in self.start_buttons.items():
if freq == active_freq:
button.config(text = "■ Stop")
else:
button.config(text = "▶ Start")
def save_threshold(self, freq):
self.stop_tone()
channel = self.selected_channel.get()
dB = self.gain_values[freq].get()
self.results[channel][freq] = round(dB, 1)
self.status_label.config(text = f"✔ {freq} Hz gespeichert für {channel} bei {dB:.1f} dB")
print(f"{channel}: {freq} Hz = {dB:.1f} dB")
if all(f in self.results[channel] for f in FREQUENCIES):
self.check_plot_ready()
def check_plot_ready(self):
if all(f in self.results["left"] for f in FREQUENCIES) and all(f in self.results["right"] for f in FREQUENCIES):
self.plot_results()
def plot_results(self):
fig, ax = plt.subplots()
x = FREQUENCIES
y_left = [self.results["left"].get(f, None) for f in x]
y_right = [self.results["right"].get(f, None) for f in x]
ax.plot(x, y_left, marker = 'o', label = 'Links')
ax.plot(x, y_right, marker = 'o', label = 'Rechts')
ax.set_xlabel("Frequenz (Hz)")
ax.set_ylabel("Hörschwelle (dB)")
ax.set_title("Hörprofil")
ax.invert_yaxis()
ax.legend()
def use_reference(channel):
ref_side = channel
other_side = "right" if channel == "left" else "left"
affected = other_side
# Differenzen für vorhandene Messpunkte
diffs = {}
for f in FREQUENCIES:
ref = self.results.get(ref_side, {}).get(f)
other = self.results.get(other_side, {}).get(f)
if ref is not None and other is not None:
diffs[f] = round(other - ref, 1)
# Update der Filter-Gains
try:
self.update_freqcomp_dual(diffs, affected_side=affected, fmin = 100.0, fmax = 8000.0, n_bands = 12)
print(f"YAML aktualisiert: {CONFIG_PATH} (Seite: {affected})")
except Exception as e:
print(f"Fehler beim YAML-Update: {e}")
btn_ax1 = plt.axes([0.15, 0.01, 0.3, 0.05])
btn1 = plt.Button(btn_ax1, "Links als Referenz")
btn1.on_clicked(lambda event: use_reference("left"))
btn_ax2 = plt.axes([0.55, 0.01, 0.3, 0.05])
btn2 = plt.Button(btn_ax2, "Rechts als Referenz")
btn2.on_clicked(lambda event: use_reference("right"))
plt.show()
def _geq_centers(self, n_bands, fmin, fmax):
return [fmin * (fmax / fmin) ** (i / (n_bands - 1)) for i in range(n_bands)]
def _interp_logx(self, measured_dict, targets):
pts = sorted((f, measured_dict.get(f, 0.0)) for f in measured_dict.keys())
if not pts:
return [0.0] * len(targets)
xs = [math.log10(f) for f, _ in pts]
ys = [g for _, g in pts]
def interp(x):
if x <= xs[0]: return ys[0]
if x >= xs[-1]: return ys[-1]
for i in range(1, len(xs)):
if x <= xs[i]:
x0, x1 = xs[i-1], xs[i]
y0, y1 = ys[i-1], ys[i]
t = (x - x0) / (x1 - x0)
return y0 + t * (y1 - y0)
out = []
for f in targets:
x = math.log10(f)
out.append(round(float(interp(x)), 1))
return out
def update_freqcomp_dual(self, diffs, affected_side, fmin = 100.0, fmax = 8000.0, n_bands = 12):
centers = self._geq_centers(n_bands, fmin, fmax)
gains_target = self._interp_logx(diffs, centers)
gains_zero = [0.0] * n_bands
with open(CONFIG_PATH, "r") as f:
cfg = yaml.safe_load(f) or {}
cfg.setdefault("filters", {})
cfg["filters"]["FreqComp_L"] = {
"type": "BiquadCombo",
"parameters": {
"type": "GraphicEqualizer",
"freq_min": float(fmin),
"freq_max": float(fmax),
"gains": gains_target if affected_side == "left" else gains_zero
}
}
cfg["filters"]["FreqComp_R"] = {
"type": "BiquadCombo",
"parameters": {
"type": "GraphicEqualizer",
"freq_min": float(fmin),
"freq_max": float(fmax),
"gains": gains_target if affected_side == "right" else gains_zero
}
}
with open(CONFIG_PATH, "w") as f:
yaml.safe_dump(cfg, f, sort_keys = False, allow_unicode = True)
print(f"FreqComp aktualisiert (Seite: {affected_side}, Bänder: {n_bands}, Bereich: {int(fmin)}–{int(fmax)} Hz)")
def on_close(self):
self.stop_tone()
self.master.destroy()
if name == "main": root = tk.Tk() app = HoertestApp(root) root.mainloop()
And here is the yaml file with the values for the left ear, which is the affected one, while the right ear is the reference and wasn't influenced:
```devices:
samplerate: 44100
chunksize: 1024
queuelimit: 4
silence_threshold: -60
silence_timeout: 3.0
capture:
type: Alsa
channels: 2
device: hw:2,0
format: S32LE
playback:
type: Alsa
channels: 2
device: hw:2,0
format: S32LE
mixers:
Stereo:
channels:
in: 2
out: 2
mapping:
- dest: 0
sources:
- channel: 0
gain: 0
mute: false
- dest: 1
sources:
- channel: 1
gain: 0
mute: false
filters:
MasterVolume:
type: Gain
parameters:
gain: -3.0
mute: false
FreqComp_L:
type: BiquadCombo
parameters:
type: GraphicEqualizer
freq_min: 100.0
freq_max: 8000.0
gains:
- 7.1
- 7.4
- 7.7
- 7.4
- 6.8
- 7.2
- 5.0
- 8.0
- 9.8
- 10.3
- 4.9
- 7.9
FreqComp_R:
type: BiquadCombo
parameters:
type: GraphicEqualizer
freq_min: 100.0
freq_max: 8000.0
gains:
- 0.0
- 0.0
- 0.0
- 0.0
- 0.0
- 0.0
- 0.0
- 0.0
- 0.0
- 0.0
- 0.0
- 0.0
pipeline:
- type: Mixer
name: Stereo
- type: Filter
channels:
- 0
- 1
names:
- MasterVolume
- type: Filter
channels:
- 0
names:
- FreqComp_L
- type: Filter
channels:
- 1
names:
- FreqComp_R
So I expect in this case a natural rise of the whole spectrum on the left side. And that's why I was asking for the best possible filter for this case. I tried the Biquad filter as well but this requires 12 different filters one for each frequency and a test with a 1 kHz sine wave doesn't gave my the expected result but this was probably my fault because a sine wave probably needs the correct q value (I used q=1) so that the 1 kHz sound is not to narrow which leads to a more crispy sound instead of a smoother one.
Ok, a graphic eq is not great for raising evenly like that. It's made up of many peaking filters, and they won't sum to flat. Try it in the gui, you will see that you get something like a comb. You could try adding a gain filter with a gain set to the average of the eq values, and then use the egraphic eq to only do the deviations from the average.
Another thing, you have a gain filter at -3 dB, but the largest gain in the graphic eq is +10.3. You may get quite heavy clipping!
Ok, thank you! I‘ll try this .. and good point with the clipping; I completely overlooked this one.