camilladsp icon indicating copy to clipboard operation
camilladsp copied to clipboard

Best filter option for hearing loss compensation

Open Martius108 opened this issue 4 months ago • 5 comments

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.

Martius108 avatar Aug 11 '25 06:08 Martius108

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?

HEnquist avatar Aug 11 '25 18:08 HEnquist

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.

Martius108 avatar Aug 12 '25 05:08 Martius108

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.

HEnquist avatar Aug 12 '25 12:08 HEnquist

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!

HEnquist avatar Aug 12 '25 12:08 HEnquist

Ok, thank you! I‘ll try this .. and good point with the clipping; I completely overlooked this one.

Martius108 avatar Aug 12 '25 16:08 Martius108