Repository: claritychallenge/clarity (language: python)

Issue:
[pre-commit.ci] pre-commit-autoupdate
<!--pre-commit.ci start-->
updates:
- [github.com/asottile/pyupgrade: v3.3.2 → v3.4.0](https://github.com/asottile/pyupgrade/compare/v3.3.2...v3.4.0)
- [github.com/charliermarsh/ruff-pre-commit: v0.0.263 → v0.0.265](https://github.com/charliermarsh/ruff-pre-commit/compare/v0.0.263...v0.0.265)
<!--pre-commit.ci end-->


Discussion / hints:

Fix patch:
diff --git a/.coveragerc b/.coveragerc
index 20a02788b..ec354caf0 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -1,4 +1,8 @@
 [run]
 omit =
   # Exclude versioneer
-  clarity/_version.py
\ No newline at end of file
+  clarity/_version.py
+[report]
+exclude_lines =
+    pragma: no cover
+    if TYPE_CHECKING:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index e5c2f92a4..b3f54d716 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -20,7 +20,7 @@ repos:
       - id: check-toml
 
   - repo: https://github.com/asottile/pyupgrade
-    rev: v3.3.1
+    rev: v3.4.0
     hooks:
       - id: pyupgrade
         args: [--py38-plus]
@@ -33,7 +33,7 @@ repos:
         additional_dependencies: ["click==8.0.4"]
 
   - repo: https://github.com/DavidAnson/markdownlint-cli2
-    rev: v0.6.0
+    rev: v0.7.1
     hooks:
       - id: markdownlint-cli2
 
@@ -73,7 +73,7 @@ repos:
 
   - repo: https://github.com/charliermarsh/ruff-pre-commit
     # Ruff version.
-    rev: "v0.0.261"
+    rev: "v0.0.265"
     hooks:
       - id: ruff
 
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
index 812dd6436..aeed1f3f0 100644
--- a/CODE_OF_CONDUCT.md
+++ b/CODE_OF_CONDUCT.md
@@ -60,7 +60,7 @@ representative at an online or offline event.
 
 Instances of abusive, harassing, or otherwise unacceptable behavior may be
 reported to the community leaders responsible for enforcement at
-claritychallengecontact@gmail.com.
+<claritychallengecontact@gmail.com>.
 All complaints will be reviewed and investigated promptly and fairly.
 
 All community leaders are obligated to respect the privacy and security of the
diff --git a/README.md b/README.md
index f20521297..975ad8107 100644
--- a/README.md
+++ b/README.md
@@ -39,7 +39,7 @@ In this repository, you will find code to support all Clarity and Cadenza Challe
   - Visit the Cadenenza Challenge [website](https://cadenzachallenge.org/) for more details.
   - Evaluation tools and a baseline system are available in the [CAD1 recipes](./recipes/cad1) folder. :new:
 - The 2nd Clarity Prediction Challenge (CPC2) is now open.   :fire::fire:
-  - Join the [Cadenza Challenge Group](https://groups.google.com/g/clarirty-challenge) to keep up-to-date on developments.
+  - Join the [Clarity Challenge Group](https://groups.google.com/g/clarity-challenge) to keep up-to-date on developments.
   - Visit the Clarity Challenge [website](https://claritychallenge.org/) for more details.
   - Evaluation tools and a baseline system will be available shortly.
 - The 4th Clarity Workshop will be held as a satellite event of Interspeech 2023. For details visit the [workshop website](https://claritychallenge.org/clarity2023-workshop/).
diff --git a/clarity/data/scene_renderer_cec1.py b/clarity/data/scene_renderer_cec1.py
index 6661a3edd..a83aacbf0 100644
--- a/clarity/data/scene_renderer_cec1.py
+++ b/clarity/data/scene_renderer_cec1.py
@@ -4,11 +4,10 @@
 from pathlib import Path
 
 import numpy as np
-import soundfile
 from scipy.signal import convolve
-from soundfile import SoundFile
 
 from clarity.data.utils import better_ear_speechweighted_snr, pad, sum_signals
+from clarity.utils.file_io import read_signal, write_signal
 
 
 class Renderer:
@@ -47,81 +46,6 @@ def __init__(
             # (the eardrum signal). e.g. num_channel = 2  => channels [1, 2, 0]
             self.channels = list(range(1, num_channels + 1)) + [0]
 
-    def read_signal(
-        self, filename, offset=0, nsamples=-1, nchannels=0, offset_is_samples=False
-    ):
-        """Read a wavefile and return as numpy array of floats.
-
-        Args:
-            filename (string): Name of file to read
-            offset (int, optional): Offset in samples or seconds from start.
-                Defaults to 0.
-            nchannels: expected number of channel (default: 0 = any number OK)
-            offset_is_samples (bool): measurement units for offset (default: False)
-
-        Returns:
-            np.ndarray: audio signal
-        """
-        wave_file = SoundFile(filename)
-
-        if nchannels not in (0, wave_file.channels):
-            raise ValueError(
-                f"Wav file ({filename}) was expected to have {nchannels} channels."
-            )
-
-        if wave_file.samplerate != self.sample_rate:
-            raise ValueError(
-                f"Sampling rate is not {self.sample_rate} for filename {filename}."
-            )
-
-        if not offset_is_samples:  # Default behaviour
-            offset = int(offset * wave_file.samplerate)
-
-        if offset != 0:
-            wave_file.seek(offset)
-
-        x = wave_file.read(frames=nsamples)
-        return x
-
-    def write_signal(
-        self,
-        filename: str,
-        signal: np.ndarray,
-        sample_rate: int,
-        floating_point: bool = True,
-    ) -> None:
-        """Write a signal as fixed or floating point wav file.
-
-        Args:
-            filename (string): Name of file to write to.
-            signal (np.ndarray): Array to write.
-            sample_rate (int): Sample Rate
-            floating_point (bool): Whether to write as subtype of floating point
-
-        Returns:
-            None: Does not return anything, writes signal to given filename.
-        """
-
-        if sample_rate != self.sample_rate:
-            logging.warning(
-                f"Sampling rate mismatch: {filename} with sample rate={sample_rate}."
-            )
-            # raise ValueError("Sampling rate mismatch")
-
-        if floating_point is False:
-            if self.test_nbits == 16:
-                subtype = "PCM_16"
-                # If signal is float and we want int16
-                signal *= 32768
-                signal = signal.astype(np.dtype("int16"))
-                assert np.max(signal) <= 32767 and np.min(signal) >= -32768
-            elif self.test_nbits == 24:
-                subtype = "PCM_24"
-        else:
-            subtype = "FLOAT"
-
-        soundfile.write(filename, signal, sample_rate, subtype=subtype)
-
     def apply_ramp(self, signal, ramp_duration):
         """Apply half cosine ramp into and out of signal.
 
@@ -219,11 +143,18 @@ def render(
             f"{self.input_path}/{dataset}/interferers/{noise_type}/{interferer_id}.wav"
         )
 
-        target = self.read_signal(target_fn)
+        target = read_signal(
+            target_fn, sample_rate=self.sample_rate, allow_resample=False
+        )
         target = np.pad(target, [(pre_samples, post_samples)])
 
-        interferer_signal = self.read_signal(
-            interferer_fn, offset=offset, nsamples=len(target), offset_is_samples=True
+        interferer_signal = read_signal(
+            interferer_fn,
+            sample_rate=self.sample_rate,
+            offset=offset,
+            n_samples=len(target),
+            offset_is_samples=True,
+            allow_resample=False,
         )
 
         if len(interferer_signal) != len(target):
@@ -246,8 +177,12 @@ def render(
             # Load scene BRIRs
             target_brir_fn = f"{brir_stem}_t_CH{channel}.wav"
             interferer_brir_fn = f"{brir_stem}_i1_CH{channel}.wav"
-            target_brir = self.read_signal(target_brir_fn)
-            interferer_brir = self.read_signal(interferer_brir_fn)
+            target_brir = read_signal(
+                target_brir_fn, sample_rate=self.sample_rate, allow_resample=False
+            )
+            interferer_brir = read_signal(
+                interferer_brir_fn, sample_rate=self.sample_rate, allow_resample=False
+            )
 
             # Apply the BRIRs
             target_at_ear = self.apply_brir(target, target_brir)
@@ -283,13 +218,17 @@ def render(
 
         if self.channels == []:
             target_brir_fn = f"{brir_stem}_t_CH0.wav"
-            target_brir = self.read_signal(target_brir_fn)
+            target_brir = read_signal(
+                target_brir_fn, sample_rate=self.sample_rate, allow_resample=False
+            )
 
         # Construct the anechoic target reference signal
         anechoic_brir_fn = (
             f"{anechoic_brir_stem}_t_CH1.wav"  # CH1 used for the anechoic signal
         )
-        anechoic_brir = self.read_signal(anechoic_brir_fn)
+        anechoic_brir = read_signal(
+            anechoic_brir_fn, sample_rate=self.sample_rate, allow_resample=False
+        )
         # Padding the anechoic brir very inefficient but keeps it simple
         anechoic_brir_pad = pad(anechoic_brir, len(target_brir))
         target_anechoic = self.apply_brir(target, anechoic_brir_pad)
@@ -298,7 +237,7 @@ def render(
 
         # Write all output files
         for filename, signal in outputs:
-            self.write_signal(filename, signal, self.sample_rate)
+            write_signal(filename, signal, self.sample_rate, strict=True)
 
 
 def check_scene_exists(scene: dict, output_path: str, num_channels: int) -> bool:
diff --git a/clarity/enhancer/gha/audiogram.py b/clarity/enhancer/gha/audiogram.py
deleted file mode 100644
index ad2b84bfe..000000000
--- a/clarity/enhancer/gha/audiogram.py
+++ /dev/null
@@ -1,75 +0,0 @@
-from __future__ import annotations
-
-from dataclasses import dataclass
-from typing import TYPE_CHECKING
-
-import numpy as np
-
-if TYPE_CHECKING:
-    from numpy import ndarray
-
-
-@dataclass
-class Audiogram:
-    """Dataclass to represent an audiogram."""
-
-    levels_l: np.ndarray
-    levels_r: np.ndarray
-    cfs: np.ndarray
-
-    @property
-    def severity(self) -> list[str]:
-        """Categorise HL severity level for the audiogram.
-
-        Note that this categorisation is different from that of the British Society of
-        Audiology, which recommends descriptors mild, moderate, severe
-        and profound for average hearing threshold levels at 250, 500,
-        1000, 2000 and 4000 Hz of 21-40 dB HL, 41-70 dB HL, 71-95 dB HL
-        and > 95 dB HL, respectively (BSA Pure-tone air-conduction and
-        bone-conduction threshold audiometry with and without masking
-        2018).
-
-        Returns:
-            str: severity level, one of SEVERE, MODERATE, MILD, NOTHING
-                for each ear [left, right]
-
-        """
-        # calculate mean hearing loss between 2 & 8 kHz
-        impairment_freqs = np.logical_and(2000 <= self.cfs, self.cfs <= 8000)
-
-        if not np.any(impairment_freqs):
-            return ["NOTHING", "NOTHING"]
-
-        severity_levels = ["NOTHING", "NOTHING"]
-        for i, levels in enumerate([self.levels_l, self.levels_r]):
-            impairment_degree = np.mean(levels[impairment_freqs])
-            if impairment_degree > 56:
-                severity_level = "SEVERE"
-            elif impairment_degree > 35:
-                severity_level = "MODERATE"
-            elif impairment_degree > 15:
-                severity_level = "MILD"
-            else:
-                severity_level = "NOTHING"
-            severity_levels[i] = severity_level
-        return severity_levels
-
-    def select_subset_of_cfs(self, selected_cfs: ndarray | list[int]) -> Audiogram:
-        """Make a new audiogram using a given subset of centre freqs.
-
-        Note, any selected_cfs that do not exist in the original audiogram
-        are simply ignored
-
-        Args:
-            selected_cfs (list): List of centre frequencies to include
-
-        Returns:
-            Audiogram: New audiogram with reduced set of frequencies
-
-        """
-        indices = [i for i, cf in enumerate(self.cfs) if cf in selected_cfs]
-        return Audiogram(
-            levels_l=self.levels_l[indices],
-            levels_r=self.levels_r[indices],
-            cfs=self.cfs[indices],
-        )
diff --git a/clarity/enhancer/gha/gainrule_camfit.py b/clarity/enhancer/gha/gainrule_camfit.py
index 713b8582f..0b63b40f8 100644
--- a/clarity/enhancer/gha/gainrule_camfit.py
+++ b/clarity/enhancer/gha/gainrule_camfit.py
@@ -7,10 +7,11 @@
 import numpy as np
 from scipy.interpolate import interp1d
 
+from clarity.utils.audiogram import Audiogram
+
 if TYPE_CHECKING:
     from numpy import ndarray
 
-    from clarity.enhancer.gha.audiogram import Audiogram
     from clarity.enhancer.gha.gha_utils import FittingParams
 
 
@@ -141,40 +142,6 @@ def isothr(vsDesF: list[int] | ndarray) -> ndarray:
     return vIsoThrDB
 
 
-def freq_interp_sh(f_in: ndarray, y_in: ndarray, f: ndarray) -> ndarray:
-    """Linear interpolation on logarithmic frequency scale.
-
-    Has samples and hold on edges.
-
-    Args:
-        f_in (ndarray): audiogram frequencies (Hz)
-        y_in (ndarray): audiogram levels
-        f (list): FFT filterbank frequencies
-
-    Returns:
-        ndarray: interpolated levels corresponding to filterbank frequencies
-
-    """
-
-    # Checks
-    if np.size(f[0]) > 1:
-        f = f[0]
-    if np.size(f_in[0]) > 1:
-        f_in = f_in[0]
-    if np.size(y_in[0]) > 1:
-        y_in = y_in[0]
-
-    vals = np.pad(
-        f_in.astype(float), 1, constant_values=((0.5 * f_in[0], 2 * f_in[-1]))
-    )
-    yvals = np.pad(y_in, 1, constant_values=((y_in[0], y_in[-1])))
-
-    y = interp1d(np.log(vals), yvals, fill_value="extrapolate")(np.log(f))
-    y = np.expand_dims(y, 0)
-
-    return y
-
-
 def gains(
     compr_thr_inputs: ndarray,
     compr_thr_gains: ndarray,
@@ -209,7 +176,8 @@ def gains(
 
 
 def gainrule_camfit_linear(
-    audiogram: Audiogram,
+    audiogram_left: Audiogram,
+    audiogram_right: Audiogram,
     sFitmodel: FittingParams,
     noisegatelevels: float | ndarray = 45.0,
     noisegateslope: float | ndarray = 1.0,
@@ -250,14 +218,13 @@ def gainrule_camfit_linear(
 
     """
 
-    intercept_frequencies = np.array(
-        [125, 250, 500, 750, 1000, 1500, 2000, 3000, 4000, 5000, 5005]
-    )
-    intercepts = np.array([-11, -10, -8, -6, 0, -1, 1, -1, 0, 1, 1])
-
-    intercepts = freq_interp_sh(
-        intercept_frequencies, intercepts, sFitmodel["frequencies"]
+    intercepts_levels_freqs = Audiogram(
+        levels=np.array([-11, -10, -8, -6, 0, -1, 1, -1, 0, 1, 1]),
+        frequencies=np.array(
+            [125, 250, 500, 750, 1000, 1500, 2000, 3000, 4000, 5000, 5005]
+        ),
     )
+    intercepts = intercepts_levels_freqs.resample(sFitmodel["frequencies"]).levels
 
     sFitmodel_frequencies = sFitmodel["frequencies"]
     sFitmodel_levels = sFitmodel["levels"]
@@ -276,9 +243,8 @@ def gainrule_camfit_linear(
     noisegate_slope = np.zeros((np.size(sFitmodel_frequencies), 2))
     insertion_gains_out = np.zeros((np.size(sFitmodel_frequencies), 2))
 
-    for i, levels in enumerate([audiogram.levels_l, audiogram.levels_r]):
-        htlside = freq_interp_sh(audiogram.cfs, levels, sFitmodel_frequencies)
-
+    for i, audiogram in enumerate([audiogram_left, audiogram_right]):
+        htlside = audiogram.resample(sFitmodel_frequencies).levels
         insertion_gains = htlside * 0.48 + intercepts
 
         # According to B. Moore (1998), "Use of a loudness model for hearing-aid
@@ -288,13 +254,10 @@ def gainrule_camfit_linear(
         insertion_gains[insertion_gains < 0] = 0
 
         # Set all gains to 0 for 0dB HL flat audiogram
-        insertion_gains = insertion_gains * htlside.any()  # any(htlside)
+        insertion_gains = insertion_gains * htlside.any()
 
         sGt[:, :, i] = np.tile(insertion_gains, (len(sFitmodel_levels), 1))
 
-        if np.size(insertion_gains[0]) == np.size(htlside):
-            insertion_gains = insertion_gains[0]
-
         insertion_gains_out[:, i] = insertion_gains
         output_levels = np.tile(sFitmodel_levels, (np.size(insertion_gains), 1))
         output_levels = sGt[:, :, i] + np.transpose(output_levels)
@@ -316,7 +279,8 @@ def gainrule_camfit_linear(
 
 
 def gainrule_camfit_compr(
-    audiogram: Audiogram,
+    audiogram_left: Audiogram,
+    audiogram_right: Audiogram,
     sFitmodel: FittingParams,
     noisegatelevels: float | ndarray = 45.0,
     noisegateslope: float | ndarray = 1.0,
@@ -340,7 +304,8 @@ def gainrule_camfit_compr(
     openMHA is free software: see licencing conditions at http://www.openmha.org/
 
     Args:
-        audiogram (Audiogram): the audiogram for which to make the fit
+        audiogram_left (Audiogram): the audiogram for the left ear
+        audiogram_right (Audiogram): the audiogram for the right ear
         sFitmodel (dict): contains the center frequencies for the amplification
             bands and input levels in SPL for which to compute the gains
         noisegatelevels (ndarray): compression threshold levels for each frequency
@@ -437,8 +402,7 @@ def gainrule_camfit_compr(
             for (ltass_a, ltass_b) in zip(LTASS_edge_freq[:-1], LTASS_edge_freq[1:])
         ]
         intensity_sum = np.inner(LTASS_intensity, portion)  # weighted sum
-        speech_level_70_in_dc_band = 10 * np.log10(intensity_sum)
-        speech_level_65_in_dc_bands[band] = speech_level_70_in_dc_band - 5
+        speech_level_65_in_dc_bands[band] = 10 * np.log10(intensity_sum) - 5
 
     # minima in lowest level speech that needs to be understood is 38 dB below
     # speech_level_65_in_dc_bands
@@ -455,8 +419,8 @@ def gainrule_camfit_compr(
     htl = np.zeros((np.size(frequencies), 2))
     Gmin = np.zeros(np.shape(htl))
 
-    for i, levels in enumerate([audiogram.levels_l, audiogram.levels_r]):
-        htl[:, i] = freq_interp_sh(audiogram.cfs, levels, frequencies)
+    for i, audiogram in enumerate([audiogram_left, audiogram_right]):
+        htl[:, i] = audiogram.resample(frequencies).levels
         Gmin[:, i] = htl[:, i] + Conv - Lmin
 
     # Get input levels
@@ -464,7 +428,12 @@ def gainrule_camfit_compr(
 
     # Calculate gains at centre frequencies
     gmid_sgt, _, _, insertion_gains = gainrule_camfit_linear(
-        audiogram, sFitmodel, noisegatelevels, noisegateslope, max_output_level
+        audiogram_left,
+        audiogram_right,
+        sFitmodel,
+        noisegatelevels,
+        noisegateslope,
+        max_output_level,
     )
 
     # Calculate compression ratios
@@ -478,7 +447,7 @@ def gainrule_camfit_compr(
     if level != 0:
         cr_idx = [i for (i, val) in enumerate(sFitmodel_levels) if val == level]
 
-    for i, levels in enumerate([audiogram.levels_l, audiogram.levels_r]):
+    for i, levels in enumerate([audiogram_left.levels, audiogram_right.levels]):
         if level != 0:
             tmp = Lmid + gmid_sgt[cr_idx, :, i].flatten() - Lmin - Gmin[:, i]
         else:
diff --git a/clarity/enhancer/gha/gha_interface.py b/clarity/enhancer/gha/gha_interface.py
index 98e79fecb..196f92782 100644
--- a/clarity/enhancer/gha/gha_interface.py
+++ b/clarity/enhancer/gha/gha_interface.py
@@ -8,11 +8,11 @@
 from pathlib import Path
 
 import numpy as np
-import soundfile
 from jinja2 import Environment, FileSystemLoader
-from soundfile import SoundFile
 
 from clarity.enhancer.gha.gha_utils import format_gaintable, get_gaintable
+from clarity.utils.audiogram import Listener
+from clarity.utils.file_io import read_signal, write_signal
 
 
 class GHAHearingAid:
@@ -96,7 +96,9 @@ def create_configured_cfgfile(
 
         return output
 
-    def process_files(self, infile_names, outfile_name, audiogram, listener=None):
+    def process_files(
+        self, infile_names: list[str], outfile_name: str, listener: Listener
+    ):
         """Process a set of input signals and generate an output.
 
         Args:
@@ -105,14 +107,20 @@ def process_files(self, infile_names, outfile_name, audiogram, listener=None):
             outfile_name (str): File in which to store output wav files
             dry_run (bool): perform dry run only
         """
-        logging.info("Processing %s with listener %s", outfile_name, listener)
+        logging.info("Processing %s with listener %s", outfile_name, listener.id)
 
-        logging.info("Audiogram severity is %s", audiogram.severity)
-        audiogram = audiogram.select_subset_of_cfs(self.audf)
+        logging.info(
+            "Audiogram severity is %s (left) and %s (right)",
+            listener.audiogram_left.severity,
+            listener.audiogram_right.severity,
+        )
+        audiogram_left = listener.audiogram_left.resample(self.audf)
+        audiogram_right = listener.audiogram_right.resample(self.audf)
 
         # Get gain table with noisegate correction
         gaintable = get_gaintable(
-            audiogram,
+            audiogram_left,
+            audiogram_right,
             self.noise_gate_levels,
             self.noise_gate_slope,
             self.cr_level,
@@ -166,7 +174,9 @@ def process_files(self, infile_names, outfile_name, audiogram, listener=None):
         os.remove(cfg_filename)
 
         # Check output signal has energy in every channel
-        sig = self.read_signal(outfile_name)
+        sig = read_signal(
+            outfile_name, sample_rate=self.sample_rate, allow_resample=False
+        )
 
         if len(np.shape(sig)) == 1:
             sig = np.expand_dims(sig, axis=1)
@@ -175,73 +185,10 @@ def process_files(self, infile_names, outfile_name, audiogram, listener=None):
             raise ValueError("Channel empty.")
 
         # Rewriting as floating point
-        self.write_signal(outfile_name, sig, floating_point=True)
+        write_signal(outfile_name, sig, self.sample_rate, floating_point=True)
 
         logging.info("OpenMHA processing complete")
 
-    # TODO: MARKED FOR DEDUPLICATION
-    # Mirrors functionality that already exists in msbg_utils
-    def read_signal(
-        self,
-        filename: str | Path,
-        offset: int = 0,
-        nsamples: int = -1,
-        nchannels: int = 0,
-        offset_is_samples: bool = False,
-    ):
-        """Read a wavefile and return as numpy array of floats.
-
-        Args:
-            filename (string): Name of file to read
-            offset (int, optional): Offset in samples or seconds (from start).
-                Defaults to 0.
-            nchannels: expected number of channel (default: 0 = any number OK)
-            offset_is_samples (bool): measurement units for offset (default: False)
-        Returns:
-            ndarray: audio signal
-        """
-
-        wave_file = SoundFile(filename)
-
-        if nchannels not in (0, wave_file.channels):
-            raise ValueError(
-                f"Wav file ({filename}) was expected to have {nchannels} channels."
-            )
-
-        if wave_file.samplerate != self.sample_rate:
-            raise ValueError(
-                f"Sampling rate is not {self.sample_rate} for filename {filename}."
-            )
-
-        if not offset_is_samples:  # Default behaviour
-            offset = int(offset * wave_file.samplerate)
-
-        if offset != 0:
-            wave_file.seek(offset)
-
-        x = wave_file.read(frames=nsamples)
-
-        return x
-
-    # TODO: MARKED FOR DEDUPLICATION
-    # Mirrors functionality that already exists in msbg_utils
-    def write_signal(
-        self, filename: str | Path, x, floating_point: bool = True, strict: bool = False
-    ) -> None:
-        """Write a signal as fixed or floating point wav file."""
-
-        if floating_point is False:
-            subtype = "PCM_16"
-            # If signal is float and we want int16
-            x = x * 32768
-            if strict and (np.max(x) > 32767 or np.min(x) < -32768):
-                raise ValueError("Signal out of range -1.0 to 1.0")
-            x = x.astype(np.dtype("int16"))
-        else:
-            subtype = "FLOAT"
-
-        soundfile.write(filename, x, self.sample_rate, subtype=subtype)
-
     def create_HA_inputs(self, infile_names: list[str], merged_filename: str) -> None:
         """Create input signal for baseline hearing aids.
 
@@ -259,8 +206,12 @@ def create_HA_inputs(self, infile_names: list[str], merged_filename: str) -> Non
         if (infile_names[0][-5] != "1") or (infile_names[2][-5] != "3"):
             raise ValueError("HA-input signal error: channel mismatch!")
 
-        signal_CH1 = self.read_signal(infile_names[0])
-        signal_CH3 = self.read_signal(infile_names[2])
+        signal_CH1 = read_signal(
+            infile_names[0], sample_rate=self.sample_rate, allow_resample=False
+        )
+        signal_CH3 = read_signal(
+            infile_names[2], sample_rate=self.sample_rate, allow_resample=False
+        )
 
         merged_signal = np.zeros((len(signal_CH1), 4))
 
@@ -273,6 +224,10 @@ def create_HA_inputs(self, infile_names: list[str], merged_filename: str) -> Non
         # channel index 3 = rear microphone on the right hearing aid
         merged_signal[:, 3] = signal_CH3[:, 1]
 
-        self.write_signal(
-            merged_filename, merged_signal, floating_point=True, strict=True
+        write_signal(
+            merged_filename,
+            merged_signal,
+            self.sample_rate,
+            floating_point=True,
+            strict=True,
         )
diff --git a/clarity/enhancer/gha/gha_utils.py b/clarity/enhancer/gha/gha_utils.py
index d0662b084..df75b6aa8 100644
--- a/clarity/enhancer/gha/gha_utils.py
+++ b/clarity/enhancer/gha/gha_utils.py
@@ -12,7 +12,7 @@
 if TYPE_CHECKING:
     from numpy import ndarray
 
-    from clarity.enhancer.gha.audiogram import Audiogram
+    from clarity.utils.audiogram import Audiogram
 
 
 class Gaintable(TypedDict):
@@ -38,7 +38,8 @@ class FittingParams(TypedDict):
 
 
 def get_gaintable(
-    audiogram: Audiogram,
+    audiogram_left: Audiogram,
+    audiogram_right: Audiogram,
     noisegate_levels: float | ndarray,
     noisegate_slope: float | ndarray,
     cr_level: float,
@@ -51,7 +52,8 @@ def get_gaintable(
     that audiogram frequencies are identical at two ears.
 
     Args:
-        audiogram (Audiogram): the audiogram for which to compute the gain table
+        audiogram_left (Audiogram): the audiogram for the left ear
+        audiogram_right (Audiogram): the audiogram for the right ear
         audf (list): audiogram frequencies for fitting
         noisegatelevels (ndarray): compression threshold levels for each frequency band
         noisegateslope (float): determines slope of gains below compression threshold
@@ -89,7 +91,8 @@ def get_gaintable(
 
     # Calculate gains and compression ratios
     gain_table, noisegate_levels, noisegate_slope = gainrule_camfit_compr(
-        audiogram,
+        audiogram_left,
+        audiogram_right,
         sFitmodel,
         noisegate_levels,
         noisegate_slope,
diff --git a/clarity/enhancer/nalr.py b/clarity/enhancer/nalr.py
index 6eb84287a..a6c1a0ec2 100644
--- a/clarity/enhancer/nalr.py
+++ b/clarity/enhancer/nalr.py
@@ -7,11 +7,15 @@
 import scipy.signal
 
 from clarity.evaluator.msbg.msbg_utils import firwin2
+from clarity.utils.audiogram import Audiogram
 
 if TYPE_CHECKING:
     from numpy import ndarray
 
 
+NALR_FREQS = np.array([250, 500, 1000, 2000, 4000, 6000])
+
+
 class NALR:
     def __init__(self, nfir: int, sample_rate: float) -> None:
         """
@@ -23,28 +27,11 @@ def __init__(self, nfir: int, sample_rate: float) -> None:
         # Processing parameters
         self.fmax = 0.5 * sample_rate
 
-        # Audiometric frequencies
-        self.aud = np.array([250, 500, 1000, 2000, 4000, 6000])
-
         # Design a flat filter having the same delay as the NAL-R filter
         self.delay = np.zeros(nfir + 1)
         self.delay[nfir // 2] = 1.0
 
-    def hl_interp(self, hl: np.ndarray, cfs: np.ndarray) -> ndarray:
-        """Interpolate the hearing loss at the audiometric frequencies."""
-        try:
-            hl_interp_fn = scipy.interpolate.interp1d(cfs, hl)
-        except ValueError as exception:
-            raise ValueError(
-                "Hearing losses (hl) and center frequencies (cfs) don't match!"
-            ) from exception
-        return hl_interp_fn(self.aud)
-
-    def build(
-        self,
-        hl: ndarray,
-        cfs: ndarray | None = None,
-    ) -> tuple[ndarray, ndarray]:
+    def build(self, audiogram: Audiogram) -> tuple[ndarray, ndarray]:
         """
         Args:
             hl: hearing thresholds at [250, 500, 1000, 2000, 4000, 6000] Hz
@@ -55,29 +42,29 @@ def build(
             delay
         """
 
-        # Apply interpolation only if cfs is not None
-        if cfs is not None:
-            hl = self.hl_interp(hl, cfs)
+        audiogram = audiogram.resample(NALR_FREQS)
 
-        max_loss = np.max(hl)
+        max_loss = np.max(audiogram.levels)
 
         if max_loss > 0:
             # Compute the NAL-R frequency response at the audiometric frequencies
             bias = np.array([-17, -8, 1, -1, -2, -2])
 
-            critical_loss = hl[1] + hl[2] + hl[3]  # <-- loss at 500 Hz to 2 kHz
+            critical_loss = (
+                audiogram.levels[1] + audiogram.levels[2] + audiogram.levels[3]
+            )  # <-- loss at 500 Hz to 2 kHz
             if critical_loss <= 180:
                 x_ave = 0.05 * critical_loss
             else:
                 x_ave = 9.0 + 0.116 * (critical_loss - 180)
-            gain_db = x_ave + 0.31 * hl + bias
+            gain_db = x_ave + 0.31 * audiogram.levels + bias
             gain_db = np.clip(gain_db, a_min=0, a_max=None)
 
             # Design the linear-phase FIR filter
 
             # Build the gain interpolation function
             freq_ext: ndarray = np.concatenate(
-                (np.array([0.0]), self.aud, np.array([self.fmax]))
+                (np.array([0.0]), NALR_FREQS, np.array([self.fmax]))
             )
             gain_db_ext = np.concatenate(([gain_db[0]], gain_db, [gain_db[-1]]))
             interp_fn = scipy.interpolate.interp1d(freq_ext, gain_db_ext)
diff --git a/clarity/evaluator/haaqi/haaqi.py b/clarity/evaluator/haaqi/haaqi.py
index 3f256c1b6..f4d19662c 100644
--- a/clarity/evaluator/haaqi/haaqi.py
+++ b/clarity/evaluator/haaqi/haaqi.py
@@ -2,12 +2,12 @@
 from __future__ import annotations
 
 import logging
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Final
 
 import numpy as np
 
 from clarity.evaluator.haspi import eb
-from clarity.utils.signal_processing import compute_rms
+from clarity.utils.audiogram import Audiogram
 
 if TYPE_CHECKING:
     from numpy import ndarray
@@ -17,13 +17,16 @@
 
 logger = logging.getLogger(__name__)
 
+# HAAQI assumes the following audiogram frequencies:
+HAAQI_AUDIOGRAM_FREQUENCIES: Final = np.array([250, 500, 1000, 2000, 4000, 6000])
+
 
 def haaqi_v1(
     reference: ndarray,
     reference_freq: float,
     processed: ndarray,
     processed_freq: float,
-    hearing_loss: ndarray,
+    audiogram: Audiogram,
     equalisation: int,
     level1: float = 65.0,
     silence_threshold: float = 2.5,
@@ -73,6 +76,14 @@ def haaqi_v1(
     Translated from MATLAB to Python by Gerardo Roa Dabike, September 2022.
     """
 
+    if not audiogram.has_frequencies(HAAQI_AUDIOGRAM_FREQUENCIES):
+        logging.warning(
+            "Audiogram does not have all HAAQI frequency measurements"
+            "Measurements will be interpolated"
+        )
+
+    audiogram = audiogram.resample(HAAQI_AUDIOGRAM_FREQUENCIES)
+
     # Auditory model for quality
     # Reference is no processing or NAL-R, impaired hearing
     (
@@ -88,7 +99,7 @@ def haaqi_v1(
         reference_freq,
         processed,
         processed_freq,
-        hearing_loss,
+        audiogram.levels,
         equalisation,
         level1,
     )
@@ -167,12 +178,10 @@ def haaqi_v1(
 def compute_haaqi(
     processed_signal: ndarray,
     reference_signal: ndarray,
-    audiogram: ndarray,
-    audiogram_frequencies: ndarray,
+    audiogram: Audiogram,
     sample_rate: float,
     equalisation: int = 1,
     level1: float = 65.0,
-    scale_reference: bool = True,
 ) -> float:
     """Compute HAAQI metric
 
@@ -182,26 +191,17 @@ def compute_haaqi(
         reference_signal (np.ndarray): Input reference speech signal with no noise
             or distortion. If a hearing loss is specified, NAL-R equalization
             is optional
-        audiogram (np.ndarray): Vector of hearing loss at the audiogram_frequencies
-        audiogram_frequencies (np.ndarray): Audiogram frequencies
+        audiogram (Audiogram): Audiogram object.
         sample_rate (int): Sample rate in Hz.
         equalisation (int): hearing loss equalization mode for reference signal:
             1 = no EQ has been provided, the function will add NAL-R
             2 = NAL-R EQ has already been added to the reference signal
             Defaults to 1.
-        level1 (float): Reference level in dB SPL. Defaults to 65.0.
-        scale_reference (bool): Scale the reference signal to RMS=1. Defaults to True.
+        level1 (float): Optional input specifying level in dB SPL
+            that corresponds to a signal RMS = 1.
+            Default is 65 dB SPL.
     """
 
-    haaqi_audiogram_frequencies = [250, 500, 1000, 2000, 4000, 6000]
-    audiogram_adjusted = np.array(
-        [
-            audiogram[i]
-            for i in range(len(audiogram_frequencies))
-            if audiogram_frequencies[i] in haaqi_audiogram_frequencies
-        ]
-    )
-
     if len(reference_signal) == 0:
         if len(processed_signal) == 0:
             # No scoring if no music
@@ -209,15 +209,12 @@ def compute_haaqi(
         logger.error("If `Reference` is empty, `Processed` must be empty as well")
         return 0.0
 
-    if scale_reference:
-        reference_signal /= compute_rms(reference_signal)
-
     score, _, _, _ = haaqi_v1(
         reference=reference_signal,
         reference_freq=sample_rate,
         processed=processed_signal,
         processed_freq=sample_rate,
-        hearing_loss=audiogram_adjusted,
+        audiogram=audiogram,
         equalisation=equalisation,
         level1=level1,
     )
diff --git a/clarity/evaluator/haspi/eb.py b/clarity/evaluator/haspi/eb.py
index f3cb7797f..32692db92 100644
--- a/clarity/evaluator/haspi/eb.py
+++ b/clarity/evaluator/haspi/eb.py
@@ -19,6 +19,7 @@
 )
 
 from clarity.enhancer.nalr import NALR
+from clarity.utils.audiogram import Audiogram
 
 if TYPE_CHECKING:
     from numpy import ndarray
@@ -148,8 +149,11 @@ def ear_model(
     if itype == 1:
         nfir = 140  # Length in samples of the FIR NAL-R EQ filter (24-kHz rate)
         enhancer = NALR(nfir, freq_sample)
-        aud = np.array([250, 500, 1000, 2000, 4000, 6000])
-        nalr_fir, _ = enhancer.build(hearing_loss, aud)
+        audiogram = Audiogram(
+            levels=hearing_loss,
+            frequencies=np.array([250, 500, 1000, 2000, 4000, 6000]),
+        )
+        nalr_fir, _ = enhancer.build(audiogram)
         reference_24hz = enhancer.apply(nalr_fir, reference_24hz)
         reference_24hz = reference_24hz[nfir : nfir + nsamp]
 
diff --git a/clarity/evaluator/haspi/haspi.py b/clarity/evaluator/haspi/haspi.py
index 5e929e619..7dae0899d 100644
--- a/clarity/evaluator/haspi/haspi.py
+++ b/clarity/evaluator/haspi/haspi.py
@@ -1,7 +1,8 @@
 """HASPI intelligibility Index"""
 from __future__ import annotations
 
-from typing import TYPE_CHECKING
+import logging
+from typing import TYPE_CHECKING, Final
 
 import numpy as np
 
@@ -13,17 +14,22 @@
     modulation_cross_correlation,
 )
 from clarity.evaluator.haspi.ip import get_neural_net, nn_feed_forward_ensemble
+from clarity.utils.audiogram import Audiogram, Listener
 
 if TYPE_CHECKING:
     from numpy import ndarray
 
 
+# HASPI assumes the following audiogram frequencies:
+HASPI_AUDIOGRAM_FREQUENCIES: Final = np.array([250, 500, 1000, 2000, 4000, 6000])
+
+
 def haspi_v2(  # pylint: disable=too-many-arguments too-many-locals
     reference: ndarray,
     reference_sample_rate: float,
     processed: ndarray,
     processed_sample_rate: float,
-    hearing_loss: ndarray,
+    audiogram: Audiogram,
     level1: float = 65.0,
     f_lp: float = 320.0,
     itype: int = 0,
@@ -72,6 +78,15 @@ def haspi_v2(  # pylint: disable=too-many-arguments too-many-locals
         Translated from MATLAB to Python by Zuzanna Podwinska, March 2022.
     """
 
+    if not audiogram.has_frequencies(HASPI_AUDIOGRAM_FREQUENCIES):
+        logging.warning(
+            "Audiogram does not have all HASPI frequency measurements"
+            "Measurements will be interpolated"
+        )
+
+    # Adjust audiogram to match the standard frequencies
+    audiogram = audiogram.resample(HASPI_AUDIOGRAM_FREQUENCIES)
+
     # Auditory model for intelligibility
     # Reference is no processing, normal hearing
     reference_env, _, processed_env, _, _, _, fsamp = ear_model(
@@ -79,7 +94,7 @@ def haspi_v2(  # pylint: disable=too-many-arguments too-many-locals
         reference_sample_rate,
         processed,
         processed_sample_rate,
-        hearing_loss,
+        audiogram.levels,
         itype,
         level1,
         # shift=0.02 # See comment in docstring
@@ -140,9 +155,7 @@ def haspi_v2_be(  # pylint: disable=too-many-arguments
     processed_left: ndarray,
     processed_right: ndarray,
     sample_rate: float,
-    audiogram_left: ndarray,
-    audiogram_right: ndarray,
-    audiogram_frequencies: ndarray,
+    listener: Listener,
     level: float = 100.0,
 ) -> float:
     """Better ear HASPI.
@@ -157,7 +170,6 @@ def haspi_v2_be(  # pylint: disable=too-many-arguments
         sample_rate (int): sampling rate for both signal
         audiogram_left (): left ear audiogram
         audiogram_right (): right ear audiogram
-        audiogram_cfs: audiogram frequencies
         level: level in dB SPL corresponding to RMS=1
 
     Returns:
@@ -167,35 +179,12 @@ def haspi_v2_be(  # pylint: disable=too-many-arguments
         Zuzanna Podwinska, March 2022
     """
 
-    # HASPI assumes the following audiogram frequencies:
-    aud = [250, 500, 1000, 2000, 4000, 6000]
-
-    # Adjust listener.audiogram_levels_l and _r to match the frequencies above
-    hearing_loss_left = np.array(
-        [
-            loss
-            for (freq, loss) in zip(audiogram_frequencies, audiogram_left)
-            if freq in aud
-        ]
-    )
-
-    hearing_loss_right = np.array(
-        [
-            loss
-            for (freq, loss) in zip(audiogram_frequencies, audiogram_right)
-            if freq in aud
-        ]
-    )
-
-    if len(hearing_loss_left) != len(aud) or len(hearing_loss_right) != len(aud):
-        raise ValueError("Audiogram does not have all measurements needed by HASPI.")
-
     score_left, _ = haspi_v2(
         reference_left,
         sample_rate,
         processed_left,
         sample_rate,
-        hearing_loss_left,
+        listener.audiogram_left,
         level,
     )
     score_right, _ = haspi_v2(
@@ -203,7 +192,7 @@ def haspi_v2_be(  # pylint: disable=too-many-arguments
         sample_rate,
         processed_right,
         sample_rate,
-        hearing_loss_right,
+        listener.audiogram_right,
         level,
     )
 
diff --git a/clarity/evaluator/hasqi/hasqi.py b/clarity/evaluator/hasqi/hasqi.py
index 7e5a87a23..b11ee47ef 100644
--- a/clarity/evaluator/hasqi/hasqi.py
+++ b/clarity/evaluator/hasqi/hasqi.py
@@ -1,21 +1,27 @@
 from __future__ import annotations
 
-from typing import TYPE_CHECKING
+import logging
+from typing import TYPE_CHECKING, Final
 
 import numpy as np
 
 from clarity.evaluator.haspi import eb
+from clarity.utils.audiogram import Audiogram, Listener
 
 if TYPE_CHECKING:
     from numpy import ndarray
 
 
+# HASQI assumes the following audiogram frequencies:
+HASQI_AUDIOGRAM_FREQUENCIES: Final = np.array([250, 500, 1000, 2000, 4000, 6000])
+
+
 def hasqi_v2(
     reference: ndarray,
     reference_sample_rate: float,
     processed: ndarray,
     processed_sample_rate: float,
-    hearing_loss: ndarray,
+    audiogram: Audiogram,
     equalisation: int = 1,
     level1: float = 65.0,
     silence_threshold: float = 2.5,
@@ -62,6 +68,14 @@ def hasqi_v2(
     Translated from MATLAB to Python by Gerardo Roa Dabike, October 2022.
     """
 
+    if not audiogram.has_frequencies(HASQI_AUDIOGRAM_FREQUENCIES):
+        logging.warning(
+            "Audiogram does not have all HASQI frequency measurements"
+            "Measurements will be interpolated"
+        )
+
+    audiogram = audiogram.resample(HASQI_AUDIOGRAM_FREQUENCIES)
+
     # Auditory model for quality
     # Reference is no processing or NAL-R, impaired hearing
     (
@@ -77,7 +91,7 @@ def hasqi_v2(
         reference_sample_rate,
         processed,
         processed_sample_rate,
-        hearing_loss,
+        audiogram.levels,
         equalisation,
         level1,
     )
@@ -160,11 +174,8 @@ def hasqi_v2_better_ear(
     processed_left: ndarray,
     processed_right: ndarray,
     sample_rate: float,
-    audiogram_left: ndarray,
-    audiogram_right: ndarray,
-    audiogram_frequencies: ndarray,
+    listener: Listener,
     level: float = 100.0,
-    audiogram_freq: None | ndarray = None,
 ) -> float:
     """Better ear HASQI.
 
@@ -178,7 +189,6 @@ def hasqi_v2_better_ear(
         sample_rate: sampling rate for both signal
         audiogram_l: left ear audiogram
         audiogram_r: right ear audiogram
-        audiogram_cfs: audiogram frequencies
         level: level in dB SPL corresponding to RMS=1
         audiogram_freq: selected frequencies to use for audiogram
 
@@ -188,40 +198,12 @@ def hasqi_v2_better_ear(
     Gerardo Roa Dabike, November 2022
     """
 
-    # Default frequencies
-    if audiogram_freq is None:
-        audiogram_freq = np.array([250, 500, 1000, 2000, 4000, 6000])
-
-    # TODO: This logic for selecting a subset of an audiogram should be moved
-    # into the audiogram class
-
-    n_freq = len(audiogram_freq)
-    # Select levels at standard frequencies
-    selected_left = np.array(
-        [
-            audiogram_left[i]
-            for i in range(len(audiogram_frequencies))
-            if audiogram_frequencies[i] in audiogram_freq
-        ]
-    )
-
-    selected_right = np.array(
-        [
-            audiogram_right[i]
-            for i in range(len(audiogram_frequencies))
-            if audiogram_frequencies[i] in audiogram_freq
-        ]
-    )
-
-    if len(selected_left) != n_freq or len(selected_right) != n_freq:
-        raise ValueError("Some requested frequencies not available in audiogram.")
-
     score_left, _, _, _ = hasqi_v2(
         reference_left,
         sample_rate,
         processed_left,
         sample_rate,
-        selected_left,
+        listener.audiogram_left,
         equalisation=1,
         level1=level,
     )
@@ -230,7 +212,7 @@ def hasqi_v2_better_ear(
         sample_rate,
         processed_right,
         sample_rate,
-        selected_right,
+        listener.audiogram_right,
         equalisation=1,
         level1=level,
     )
diff --git a/clarity/evaluator/msbg/audiogram.py b/clarity/evaluator/msbg/audiogram.py
deleted file mode 100644
index 89acad54c..000000000
--- a/clarity/evaluator/msbg/audiogram.py
+++ /dev/null
@@ -1,101 +0,0 @@
-"""Supplied dataclass to represent a monaural audiogram"""
-from __future__ import annotations
-
-from dataclasses import dataclass
-from typing import Final
-
-import numpy as np
-
-DEFAULT_CLARITY_CFS: Final = (250, 500, 1000, 2000, 3000, 4000, 6000, 8000)
-
-FULL_STANDARD_CFS: Final = np.array(
-    [
-        125,
-        250,
-        500,
-        750,
-        1000,
-        1500,
-        2000,
-        3000,
-        4000,
-        6000,
-        8000,
-        10000,
-        12000,
-        14000,
-        16000,
-    ]
-)
-
-
-@dataclass
-class Audiogram:
-    """Dataclass to represent an audiogram.
-
-    Attributes:
-        levels (ndarray): The levels for the left and right ear
-        cfs (ndarray): The centre-frequencies at which the levels are measured
-    """
-
-    levels: np.ndarray
-    cfs: np.ndarray = np.array(DEFAULT_CLARITY_CFS)
-
-    @property
-    def severity(self) -> str:
-        """Categorise HL severity level for the audiogram.
-
-        Note that this categorisation is different from that of the British
-        Society of Audiology, which recommends descriptors mild, moderate,
-        severe and profound for average hearing threshold levels at 250, 500,
-        1000, 2000 and 4000 Hz of 21-40 dB HL, 41-70 dB HL, 71-95 dB HL
-        and > 95 dB HL, respectively (BSA Pure-tone air-conduction and
-        bone-conduction threshold audiometry with and without masking
-        2018).
-
-        Returns:
-            str -- severity level, one of SEVERE, MODERATE, MILD, NOTHING
-
-        """
-        # calculate mean hearing loss between critical frequencies of 2 & 8 kHz
-        critical_freqs = np.logical_and(2000 <= self.cfs, self.cfs <= 8000)
-        critical_levels = self.levels[critical_freqs]
-        # Remove any None values
-        critical_levels = [x for x in critical_levels if x is not None]
-        # Ignore any None values
-        impairment_degree = np.mean(critical_levels) if len(critical_levels) > 0 else 0
-
-        if impairment_degree > 56:
-            return "SEVERE"
-        if impairment_degree > 35:
-            return "MODERATE"
-        if impairment_degree > 15:
-            return "MILD"
-
-        return "NOTHING"
-
-
-# Reference processing: use to check levels between original and processed,
-AUDIOGRAM_REF = Audiogram(
-    cfs=FULL_STANDARD_CFS,
-    levels=np.array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
-)
-
-# mild age-related hearing loss, slightly reduced from CF, first-time aid wearers group
-# (used in Stafa talk by MAS)
-AUDIOGRAM_MILD = Audiogram(
-    cfs=FULL_STANDARD_CFS,
-    levels=np.array([5, 10, 15, 18, 19, 22, 25, 28, 31, 35, 38, 40, 40, 45, 50]),
-)
-
-# mod hearing loss based on mild N2 flat/mod sloping from Bisgaard et al. 2020
-AUDIOGRAM_MODERATE = Audiogram(
-    cfs=FULL_STANDARD_CFS,
-    levels=np.array([15, 20, 20, 22.5, 25, 30, 35, 40, 45, 50, 55, 55, 60, 65, 65]),
-)
-
-# (mod-)severe age-related hearing loss, average of MAS/KA summer proj 2011, elderly HI
-AUDIOGRAM_MODERATE_SEVERE = Audiogram(
-    cfs=FULL_STANDARD_CFS,
-    levels=np.array([19, 19, 28, 35, 40, 47, 52, 56, 58, 58, 63, 70, 75, 80, 80]),
-)
diff --git a/clarity/evaluator/msbg/cochlea.py b/clarity/evaluator/msbg/cochlea.py
index a90cc5a19..9567dd432 100644
--- a/clarity/evaluator/msbg/cochlea.py
+++ b/clarity/evaluator/msbg/cochlea.py
@@ -10,9 +10,9 @@
 from numpy import ndarray
 from scipy import signal
 
-from clarity.evaluator.msbg.audiogram import Audiogram
 from clarity.evaluator.msbg.msbg_utils import read_gtf_file
 from clarity.evaluator.msbg.smearing import Smearer
+from clarity.utils.audiogram import Audiogram
 
 # TODO: Fix power overflow error when (expansion_ratios[ixch] - 1) < 0
 
@@ -67,16 +67,16 @@ def compute_recruitment_parameters(
     cf_expansion = np.zeros(gtn_cf.shape)  # expansion ratios
 
     for ix_cf in np.arange(0, gtn_cf.shape[0]):
-        if gtn_cf[ix_cf] < audiogram.cfs[0]:
+        if gtn_cf[ix_cf] < audiogram.frequencies[0]:
             # Extend audiogram, flat below lowest freq measured
             cf_expansion[ix_cf] = catch_up / (catch_up - audiogram.levels[0])
-        elif gtn_cf[ix_cf] > audiogram.cfs[-1]:
+        elif gtn_cf[ix_cf] > audiogram.frequencies[-1]:
             # Extend audiogram, flat above highest freq measured
             cf_expansion[ix_cf] = catch_up / (catch_up - audiogram.levels[-1])
         else:
             # In the sensible region
             cf_to_level_fn = scipy.interpolate.interp1d(
-                audiogram.cfs, audiogram.levels, kind="linear"
+                audiogram.frequencies, audiogram.levels, kind="linear"
             )
             # Assumes catch-up at catch_up dB (typ 100-105)
             cf_expansion[ix_cf] = catch_up / (catch_up - cf_to_level_fn(gtn_cf[ix_cf]))
diff --git a/clarity/evaluator/msbg/msbg.py b/clarity/evaluator/msbg/msbg.py
index 80dd4e161..25ca058b8 100644
--- a/clarity/evaluator/msbg/msbg.py
+++ b/clarity/evaluator/msbg/msbg.py
@@ -10,7 +10,6 @@
 from numpy import ndarray
 from scipy.signal import firwin, lfilter
 
-from clarity.evaluator.msbg.audiogram import Audiogram
 from clarity.evaluator.msbg.cochlea import Cochlea
 from clarity.evaluator.msbg.msbg_utils import (
     DF_ED,
@@ -24,6 +23,7 @@
     gen_tone,
     measure_rms,
 )
+from clarity.utils.audiogram import Audiogram
 
 # Cut off frequency of low-pass filter at end of simulations:
 # prevents possible excessive processing noise at high frequencies.
diff --git a/clarity/evaluator/msbg/msbg_utils.py b/clarity/evaluator/msbg/msbg_utils.py
index 7e5ab24df..9b6e577c9 100644
--- a/clarity/evaluator/msbg/msbg_utils.py
+++ b/clarity/evaluator/msbg/msbg_utils.py
@@ -10,9 +10,7 @@
 import numpy as np
 import scipy
 import scipy.signal
-import soundfile
-from numpy import float64, ndarray
-from soundfile import SoundFile
+from numpy import ndarray
 
 # measure rms parameters
 WIN_SECS: Final = 0.01
@@ -356,10 +354,10 @@ def gen_eh2008_speech_noise(
 
 def generate_key_percent(
     signal: ndarray,
-    threshold_db: float64,
+    threshold_db: float,
     window_length: int,
     percent_to_track: float | None = None,
-) -> tuple[ndarray, float64]:
+) -> tuple[ndarray, float]:
     """Generate key percent.
     Locates frames above some energy threshold or tracks a certain percentage
     of frames. To track a certain percentage of frames in order to get measure
@@ -376,7 +374,7 @@ def generate_key_percent(
         ValueError: percent_to_track is set too high.
 
     Returns:
-        (tuple): containig
+        (tuple): containing
         - key (ndarray): The key array of indices of samples used in rms calculation.
         - used_threshold_db (float): Root Mean Squared threshold.
 
@@ -523,88 +521,3 @@ def pad(signal: ndarray, length: int) -> ndarray:
     return np.pad(
         signal, [(0, length - signal.shape[0])] + [(0, 0)] * (len(signal.shape) - 1)
     )
-
-
-def read_signal(
-    filename: str | Path,
-    offset: int = 0,
-    nsamples: int = -1,
-    nchannels: int = 0,
-    offset_is_samples: bool = False,
-) -> ndarray:
-    """Read a wavefile and return as numpy array of floats.
-
-    Args:
-        filename (str|Path): Name of file to read
-        offset (int, optional): Offset in samples or seconds (from start). Default is 0.
-        nsamples (int): Number of samples.
-        nchannels (int): expected number of channel (default: 0 = any number OK)
-        offset_is_samples (bool): measurement units for offset (default: False)
-
-    Returns:
-        np.ndarray: audio signal
-    """
-
-    wave_file = SoundFile(filename)
-
-    if nchannels not in (0, wave_file.channels):
-        raise ValueError(
-            f"Wav file ({filename}) was expected to have {nchannels} channels."
-        )
-
-    if not offset_is_samples:  # Default behaviour
-        offset = int(offset * wave_file.samplerate)
-
-    if offset != 0:
-        wave_file.seek(offset)
-
-    signal = wave_file.read(frames=nsamples)
-
-    if wave_file.samplerate != MSBG_FS:
-        signal = scipy.signal.resample(
-            signal, int(MSBG_FS * signal.shape[0] / wave_file.samplerate)
-        )
-
-    return signal
-
-
-def write_signal(
-    filename: str | Path,
-    signal: ndarray,
-    sample_rate: float,
-    floating_point: bool = True,
-    strict: bool = False,
-) -> None:
-    """Write a signal as fixed or floating point wav file.
-
-    NB: setting 'strict' to True will raise error on overflow. This would be
-    a more natural default but it would break existing code that did not
-    check for overflow.
-
-    Args:
-        filename (str|Path): name of file in to write to.
-        signal (ndarray): signal to write.
-        sample_rate (float): sampling frequency.
-        floating_point (bool): write as floating point else an ints (default: True).
-        strict (bool): raise error if signal out of range for int16 (default: False).
-    """
-
-    if sample_rate != MSBG_FS:
-        logging.warning(
-            f"Sampling rate mismatch: {filename} with "
-            f"sample frequency = {sample_rate}."
-        )
-        # raise ValueError("Sampling rate mismatch")
-
-    if floating_point is False:
-        subtype = "PCM_16"
-        # Signal is float and we want to convert to int16
-        # *NB* Not  *= in next line as we need to make a copy
-        signal = signal * 32768
-        if strict and (np.max(signal) > 32767 or np.min(signal) < -32768):
-            raise ValueError("Signal out of range -1.0 to 1.0")
-        signal = signal.astype(np.dtype("int16"))
-    else:
-        subtype = "FLOAT"
-
-    soundfile.write(filename, signal, sample_rate, subtype=subtype)
diff --git a/clarity/predictor/torch_msbg.py b/clarity/predictor/torch_msbg.py
index 9f42ac9ae..277de7bd0 100644
--- a/clarity/predictor/torch_msbg.py
+++ b/clarity/predictor/torch_msbg.py
@@ -381,27 +381,29 @@ def smear(self, x: torch.Tensor) -> torch.Tensor:
             hop_length=self.smear_hop_len,
             win_length=self.smear_win_len,
             window=self.smear_window,
+            return_complex=True,
         )
-        power = torch.square(spec[:, : self.smear_nfft // 2, :, 0]) + torch.square(
-            spec[:, : self.smear_nfft // 2, :, 1]
-        )
-        mag = torch.sqrt(power + EPS).unsqueeze(-1)
-        phasor = spec[:, : self.smear_nfft // 2, :, :] / (mag + EPS)
 
+        mag = torch.abs(spec[:, : self.smear_nfft // 2, :])
+        power = torch.square(mag)
+
+        phasor = spec[:, : self.smear_nfft // 2, :] / (mag + EPS)
         smeared_power = (
-            torch.matmul(power.transpose(-1, -2), self.f_smear.transpose(0, 1))
-            .transpose(-1, -2)
-            .unsqueeze(-1)
+            torch.matmul(
+                power.transpose(-1, -2), self.f_smear.transpose(0, 1)
+            ).transpose(-1, -2)
             + EPS
         )
         smeared_power = torch.clamp(smeared_power, min=0)
         smeared_spec_nyquist = torch.sqrt(smeared_power + EPS) * phasor
         smeared_spec_mid = torch.zeros(
-            [smeared_power.shape[0], 1, smeared_power.shape[2], 2],
+            [smeared_power.shape[0], 1, smeared_power.shape[2]],
             dtype=torch.float32,
             device=self.device,
         )
+
         smeared_spec = torch.cat([smeared_spec_nyquist, smeared_spec_mid], dim=1)
+
         smeared_wav = torch.istft(
             smeared_spec,
             n_fft=self.smear_nfft,
diff --git a/clarity/predictor/torch_stoi.py b/clarity/predictor/torch_stoi.py
index 5b088957f..7581f3dbd 100644
--- a/clarity/predictor/torch_stoi.py
+++ b/clarity/predictor/torch_stoi.py
@@ -85,7 +85,7 @@ def __init__(
             self.resample = torchaudio.transforms.Resample(
                 orig_freq=self.sample_rate,
                 new_freq=FS,
-                resampling_method="sinc_interpolation",
+                resampling_method="sinc_interp_hann",
             )
         self.win_len = (N_FRAME * sample_rate) // FS
         self.nfft = 2 * self.win_len
diff --git a/clarity/utils/audiogram.py b/clarity/utils/audiogram.py
new file mode 100644
index 000000000..712f40ca2
--- /dev/null
+++ b/clarity/utils/audiogram.py
@@ -0,0 +1,265 @@
+"""Dataclass to represent a monaural audiogram"""
+from __future__ import annotations
+
+import json
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import TYPE_CHECKING, Callable, Final
+
+import numpy as np
+
+if TYPE_CHECKING:
+    from numpy import ndarray
+
+DEFAULT_CLARITY_AUDIOGRAM_FREQUENCIES: Final = np.array(
+    [
+        250,
+        500,
+        1000,
+        2000,
+        3000,
+        4000,
+        6000,
+        8000,
+    ]
+)
+
+FULL_STANDARD_AUDIOGRAM_FREQUENCIES: Final = np.array(
+    [
+        125,
+        250,
+        500,
+        750,
+        1000,
+        1500,
+        2000,
+        3000,
+        4000,
+        6000,
+        8000,
+        10000,
+        12000,
+        14000,
+        16000,
+    ]
+)
+
+
+@dataclass
+class Audiogram:
+    """Dataclass to represent an audiogram.
+
+    Attributes:
+        levels (ndarray): The audiometric levels in dB HL
+        frequencies (ndarray): The frequencies at which the levels are measured
+
+    """
+
+    levels: np.ndarray
+    frequencies: np.ndarray = field(
+        default_factory=lambda: np.array(DEFAULT_CLARITY_AUDIOGRAM_FREQUENCIES)
+    )
+
+    def __post_init__(self) -> None:
+        """Check that dimensions of levels and frequencies match."""
+
+        # Ensure that levels and frequencies are numpy arrays
+        self.levels = np.array(self.levels)
+        self.frequencies = np.array(self.frequencies)
+
+        if len(self.levels) != len(self.frequencies):
+            raise ValueError(
+                f"Levels ({len(self.levels)}) and frequencies ({len(self.frequencies)})"
+                " must have the same length"
+            )
+
+        if len(self.frequencies) != len(np.unique(self.frequencies)):
+            raise ValueError("Frequencies must be unique")
+
+        if not np.all(np.diff(self.frequencies) > 0):
+            raise ValueError("Frequencies must be in ascending order")
+
+    @property
+    def severity(self) -> str:
+        """Categorise HL severity level for the audiogram.
+
+        Note that this categorisation is different from that of the British
+        Society of Audiology, which recommends descriptors mild, moderate,
+        severe and profound for average hearing threshold levels at 250, 500,
+        1000, 2000 and 4000 Hz of 21-40 dB HL, 41-70 dB HL, 71-95 dB HL
+        and > 95 dB HL, respectively (BSA Pure-tone air-conduction and
+        bone-conduction threshold audiometry with and without masking
+        2018).
+
+        Returns:
+            str -- severity level, one of SEVERE, MODERATE, MILD, NOTHING
+
+        """
+        # calculate mean hearing loss between critical frequencies of 2 & 8 kHz
+        critical_freqs = np.logical_and(
+            2000 <= self.frequencies, self.frequencies <= 8000
+        )
+        critical_levels = self.levels[critical_freqs]
+        # Remove any None values
+        critical_levels = [x for x in critical_levels if x is not None]
+        # Ignore any None values
+        impairment_degree = np.mean(critical_levels) if len(critical_levels) > 0 else 0
+
+        if impairment_degree > 56.0:
+            return "SEVERE"
+        if impairment_degree > 35.0:
+            return "MODERATE"
+        if impairment_degree > 15.0:
+            return "MILD"
+
+        return "NOTHING"
+
+    def has_frequencies(self, frequencies: ndarray) -> bool:
+        """Check if the audiogram has the given frequencies.
+
+        Args:
+            frequencies (ndarray): The frequencies to check
+
+        Returns:
+            bool: True if the audiogram has the given frequencies
+
+        """
+        return np.all(np.isin(frequencies, self.frequencies, assume_unique=True))
+
+    def resample(
+        self, new_frequencies: ndarray, linear_frequency: bool = False
+    ) -> Audiogram:
+        """Resample the audiogram to a new set of frequencies.
+
+        Interpolates linearly on a (by default) log frequency axis. If
+        linear_frequencies is set True then interpolation is done on a linear
+        frequency axis.
+
+        Args:
+            new_frequencies (ndarray): The new frequencies to resample to
+
+        Returns:
+            Audiogram: New audiogram with resampled frequencies
+
+        """
+
+        # Either log frequency scaling or linear frequency scaling
+        axis_fn: Callable = (lambda x: x) if linear_frequency else np.log
+
+        return Audiogram(
+            levels=np.interp(
+                axis_fn(new_frequencies),
+                axis_fn(self.frequencies),
+                self.levels,
+                left=self.levels[0],
+                right=self.levels[-1],
+            ),
+            frequencies=new_frequencies,
+        )
+
+
+# Reference audiograms originally defined in the Cambridge group
+# MSBG model MATLAB code.
+
+# No loss
+AUDIOGRAM_REF: Final = Audiogram(
+    frequencies=FULL_STANDARD_AUDIOGRAM_FREQUENCIES,
+    levels=np.array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
+)
+
+# No loss
+AUDIOGRAM_REF_CLARITY: Final = Audiogram(
+    frequencies=DEFAULT_CLARITY_AUDIOGRAM_FREQUENCIES,
+    levels=np.array([0, 0, 0, 0, 0, 0, 0, 0]),
+)
+
+# Mild age-related hearing loss, slightly reduced from CF, first-time aid
+# wearers group (used in Stafa talk by MAS).
+AUDIOGRAM_MILD: Final = Audiogram(
+    frequencies=FULL_STANDARD_AUDIOGRAM_FREQUENCIES,
+    levels=np.array([5, 10, 15, 18, 19, 22, 25, 28, 31, 35, 38, 40, 40, 45, 50]),
+)
+
+# Moderate hearing loss based on mild N2 flat/mod sloping
+# from Bisgaard et al. 2020
+AUDIOGRAM_MODERATE: Final = Audiogram(
+    frequencies=FULL_STANDARD_AUDIOGRAM_FREQUENCIES,
+    levels=np.array([15, 20, 20, 22.5, 25, 30, 35, 40, 45, 50, 55, 55, 60, 65, 65]),
+)
+
+# Moderate-severe age-related hearing loss, average of MAS/KA summer proj 2011,
+# elderly HI
+AUDIOGRAM_MODERATE_SEVERE: Final = Audiogram(
+    frequencies=FULL_STANDARD_AUDIOGRAM_FREQUENCIES,
+    levels=np.array([19, 19, 28, 35, 40, 47, 52, 56, 58, 58, 63, 70, 75, 80, 80]),
+)
+
+
+@dataclass
+class Listener:
+    """Dataclass to represent a Listener.
+
+    The listener is currently defined by their left and right ear
+    audiogram. In later versions, this may be extended to include
+    further audiometric data.
+
+    The class provides methods for reading metadata files which
+    will also include some basic validation.
+
+    Attributes:
+        id (str): The ID of the listener
+        audiogram_left (Audiogram): The audiogram for the left ear
+        audiogram_right (Audiogram): The audiogram for the right ear
+    """
+
+    audiogram_left: Audiogram
+    audiogram_right: Audiogram
+    id: str = ""
+
+    @staticmethod
+    def from_dict(listener_dict: dict) -> Listener:
+        """Create a Listener from a dict.
+
+        The dict structure and fields are based on those used
+        in the Clarity metadata files.
+
+        Args:
+            listener_dict (dict): The listener dict
+
+        Returns:
+            Listener: The listener
+
+        """
+        return Listener(
+            id=listener_dict["name"],
+            audiogram_left=Audiogram(
+                levels=listener_dict["audiogram_levels_l"],
+                frequencies=listener_dict["audiogram_cfs"],
+            ),
+            audiogram_right=Audiogram(
+                levels=listener_dict["audiogram_levels_r"],
+                frequencies=listener_dict["audiogram_cfs"],
+            ),
+        )
+
+    @staticmethod
+    def load_listener_dict(filename: Path) -> dict[str, Listener]:
+        """Read a Clarity Listener dict file.
+
+        The standard Clarity metadata files presents listeners as a
+        dictionary of listeners, keyed by listener ID.
+
+        Args:
+            filename (Path): The path to the listener dict file
+
+        Returns:
+            dict[str, Listener]: A dict of listeners keyed by id
+
+        """
+        with open(filename, encoding="utf-8") as fp:
+            listeners_raw = json.load(fp)
+
+        listeners = {}
+        for listener_id, listener_dict in listeners_raw.items():
+            listeners[listener_id] = Listener.from_dict(listener_dict)
+        return listeners
diff --git a/clarity/utils/file_io.py b/clarity/utils/file_io.py
index 42c208ada..0c9f3393a 100644
--- a/clarity/utils/file_io.py
+++ b/clarity/utils/file_io.py
@@ -1,5 +1,18 @@
-"""File I/O functions for jsonl files."""
+"""File I/O functions."""
+from __future__ import annotations
+
 import json
+import logging
+from pathlib import Path
+
+import numpy as np
+import soundfile
+from numpy import ndarray
+from soundfile import SoundFile
+
+from clarity.utils.signal_processing import resample
+
+# Function for reading and writing jsonl files
 
 
 def read_jsonl(filename: str) -> list:
@@ -14,3 +27,135 @@ def write_jsonl(filename: str, records: list) -> None:
     with open(filename, "a", encoding="utf-8") as fp:
         for record in records:
             fp.write(json.dumps(record) + "\n")
+
+
+# Functions for reading and writing audio signals.
+#
+# - Signals are passed and returns as numpy arrays of floats of shape
+#   (n_samples, n_channels). Mono signals can be passed as (n_samples, 1)
+#   but are returned as (n_samples,) which is more convenient for most purposes.
+#   Floating point and 16 bit integer file formats are supported.
+# - Signals are assumed to be in the range [-1.0 to 1.0) and will be scaled to
+#   and from -32768 to 32767 when writing to or reading from 16 bit files.
+#   On writing to 16 bit files, overflow can be set to silently wrap or to raise
+#   an error.
+# - Any sample rate can be used for writing and floating points values are
+#   rounded to integers. For reading, the 'expected sample rate' is supplied and
+#   the file is resample if necessary or can be set to throw an error. Again,
+#   this is more convenient for most purposes.
+
+
+def write_signal(
+    filename: str | Path,
+    signal: ndarray,
+    sample_rate: float,
+    floating_point: bool = True,
+    strict: bool = False,
+) -> None:
+    """Write a signal as fixed or floating point wav file.
+
+    Signals are passed as numpy arrays of floats of shape (n_samples, n_channels)
+    for n_channels >= 1 or (n_samples,) for n_channels = 1.
+
+    Signals are floating point in the range [-1.0 to 1.0) but can be written
+    as wav file with either 16 bit integers or floating point. In the former,
+    the signals are scaled to map to the range -32768 to 32767 and clipped
+    if necessary.
+
+    NB: setting 'strict' to True will raise error on overflow. This would be
+    a more natural default but it would break existing code that did not
+    check for overflow.
+
+    Args:
+        filename (str|Path): name of file in to write to.
+        signal (ndarray): signal to write.
+        sample_rate (float): sampling frequency.
+        floating_point (bool): write as floating point else an ints (default: True).
+        strict (bool): raise error if signal out of range for int16 (default: False).
+    """
+
+    if floating_point is False:
+        subtype = "PCM_16"
+        # Signal is float and we want to convert to int16
+        # *NB* Not  *= in next line as we need to make a copy
+        signal = signal * 32768.0
+        if np.max(signal) > 32767.0 or np.min(signal) < -32768.0:
+            if strict:
+                raise ValueError("Signal out of range [-1.0, 1.0)")
+            logging.warning(
+                f"Writing {filename}. Signal out of range [-1.0, 1.0) - clipping."
+            )
+            signal = np.clip(signal, -32768.0, 32767.0)
+        signal = signal.astype(np.dtype("int16"))
+    else:
+        subtype = "FLOAT"
+
+    soundfile.write(filename, signal, sample_rate, subtype=subtype)
+
+
+def read_signal(
+    filename: str | Path,
+    sample_rate: float = 0.0,
+    offset: int | float = 0,  # offset can be in samples or seconds
+    n_samples: int = -1,
+    n_channels: int = 0,
+    offset_is_samples: bool = False,
+    allow_resample: bool = True,
+) -> ndarray:
+    """Read a wav format audio file and return as numpy array of floats.
+
+    The returned value will be a numpy array of floats of shape (n_samples,
+    n_channels) for n_channels >= 2, and shape (n_samples,) for n_channels = 1.
+
+    If n_samples is set to a value other than -1, the specified number of samples
+    will be read, or until the end of the file. An 'offset' can be set to start
+    reading from a specified sample or time (in seconds).
+
+    The expected number of channels can be specified. If the file has a different
+    number of channels, an error will be raised.
+
+    The expected sample rate can be specified. If the file has a different sample
+    the file will be resampled to the expected sample rate, unless
+    'allow_resample' is set to False, in which case an error will be raised.
+
+    Args:
+        filename (str|Path): Name of file to read
+        sample_rate (float): The expected sample rate (default: 0.0 = any rate OK)
+        offset (int, optional): Offset in samples or seconds (from start).
+            Default is 0.
+        n_samples (int): Number of samples.
+        n_channels (int): expected number of channel (default: 0 = any number OK)
+        offset_is_samples (bool): is offset measured in samples, (True) or
+            seconds (False) (default: False)
+        allow_resample (bool): allow resampling if sample rate is different
+            from expected rate. Else raise error (default: True)
+
+    Returns:
+        np.ndarray: audio signal
+    """
+
+    wave_file = SoundFile(filename)
+
+    if n_channels not in (0, wave_file.channels):
+        raise ValueError(
+            f"Wav file ({filename}) was expected to have {n_channels} channels."
+        )
+
+    if not offset_is_samples:  # Default behaviour
+        offset = np.rint(offset * wave_file.samplerate).astype(int)
+
+    if offset != 0:
+        wave_file.seek(offset)
+
+    signal = wave_file.read(frames=n_samples)
+
+    if sample_rate not in (0, wave_file.samplerate):
+        if allow_resample:
+            signal = resample(signal, wave_file.samplerate, sample_rate)
+        else:
+            raise ValueError(
+                f"Sample rate of {wave_file.samplerate} "
+                "does not match expected {sample_rate}"
+            )
+
+    return signal
diff --git a/clarity/utils/signal_processing.py b/clarity/utils/signal_processing.py
index 8297f4b75..0e9078ee6 100644
--- a/clarity/utils/signal_processing.py
+++ b/clarity/utils/signal_processing.py
@@ -2,9 +2,12 @@
 from __future__ import annotations
 
 import numpy as np
+import scipy
+import soxr
+from numpy import ndarray
 
 
-def compute_rms(signal: np.ndarray) -> float:
+def compute_rms(signal: ndarray) -> float:
     """Compute RMS of signal
 
     Args:
@@ -17,19 +20,19 @@ def compute_rms(signal: np.ndarray) -> float:
     return np.sqrt(np.mean(np.square(signal)))
 
 
-def denormalize_signals(sources: np.ndarray, ref: np.ndarray) -> np.ndarray:
+def denormalize_signals(sources: ndarray, ref: ndarray) -> ndarray:
     """Scale signals back to the original scale.
 
     Args:
-        sources (np.ndarray): Source to be scaled.
-        ref (np.ndarray): Original sources to be used for reverting scaling.
+        sources (ndarray): Source to be scaled.
+        ref (ndarray): Original sources to be used for reverting scaling.
 
     Returns:
-        np.ndarray: Signal rescaled back to its original."""
+        ndarray: Signal rescaled back to its original."""
     return sources * ref.std() + ref.mean()
 
 
-def normalize_signal(signal: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
+def normalize_signal(signal: ndarray) -> tuple[ndarray, ndarray]:
     """Standardize the signal to have zero mean and unit variance.
 
     Args:
@@ -39,3 +42,47 @@ def normalize_signal(signal: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
     """
     ref = signal.mean(0)
     return (signal - ref.mean()) / ref.std(), ref
+
+
+def resample(
+    signal: ndarray,
+    sample_rate: float,
+    new_sample_rate: float,
+    method: str = "soxr",
+) -> ndarray:
+    """Resample a signal to a new sample rate.
+
+    This is a simple wrapper around  soxr and scipy.signal.resample with the resampling
+    expressed in terms of input and output sampling rates.
+
+    It also ensures that for multichannel signals, resampling is in the time
+    domain, i.e. down the columns.
+
+    Args:
+        signal: The signal to be resampled.
+        sample_rate: The original sample rate.
+        new_sample_rate: The new sample rate.
+        method: determine the approach use.
+    Returns:
+        The resampled signal.
+    """
+    if sample_rate == new_sample_rate:
+        return signal
+
+    if method == "soxr":
+        return soxr.resample(signal, sample_rate, new_sample_rate, quality="HQ")
+
+    if method == "polyphase":
+        sample_rate = int(sample_rate)
+        new_sample_rate = int(new_sample_rate)
+        gcd = np.gcd(sample_rate, new_sample_rate)
+        uprate = new_sample_rate // gcd
+        downrate = sample_rate // gcd
+        return scipy.signal.resample_poly(signal, up=uprate, down=downrate)
+
+    if method == "fft":
+        return scipy.signal.resample(
+            signal, int(new_sample_rate * signal.shape[0] / sample_rate)
+        )
+
+    raise ValueError(f"Unknown resampling method: {method}")
diff --git a/docs/CODE_OF_CONDUCT.md b/docs/CODE_OF_CONDUCT.md
index 1090350e4..d4362cc9a 100644
--- a/docs/CODE_OF_CONDUCT.md
+++ b/docs/CODE_OF_CONDUCT.md
@@ -60,7 +60,7 @@ representative at an online or offline event.
 
 Instances of abusive, harassing, or otherwise unacceptable behavior may be
 reported to the community leaders responsible for enforcement at
-claritychallengecontact@gmail.com.
+<claritychallengecontact@gmail.com>.
 All complaints will be reviewed and investigated promptly and fairly.
 
 All community leaders are obligated to respect the privacy and security of the
diff --git a/pyproject.toml b/pyproject.toml
index d0853d528..5380d9f05 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -33,22 +33,24 @@ requires-python = ">=3.8"
 dependencies = [
   "audioread>=2.1.9",
   "gdown",
-  "hydra-core==1.1.1",
+  "hydra-core>=1.1.1",
   "hydra-submitit-launcher>=1.1.6",
   "importlib-metadata",
   "librosa>=0.8.1",
   "matplotlib",
+  "numba>=0.57.0rc",
   "numpy>=1.21.6",
   "omegaconf>=2.1.1",
   "pandas>=1.3.5",
   "pyloudnorm>=0.1.0",
   "pystoi",
-  "pytorch-lightning<2.0",
+  "pytorch-lightning",
   "resampy",
   "scikit-learn>=1.0.2",
   "scipy>=1.7.3",
   "SoundFile>=0.10.3.post1",
-  "torch<2.0",
+  "soxr",
+  "torch>=2",
   "torchaudio",
   "tqdm>=4.62.3",
   "typing_extensions",
diff --git a/recipes/cad1/task1/baseline/enhance.py b/recipes/cad1/task1/baseline/enhance.py
index f73cc0198..1daf24a92 100644
--- a/recipes/cad1/task1/baseline/enhance.py
+++ b/recipes/cad1/task1/baseline/enhance.py
@@ -19,6 +19,7 @@
 
 from clarity.enhancer.compressor import Compressor
 from clarity.enhancer.nalr import NALR
+from clarity.utils.audiogram import Audiogram, Listener
 from clarity.utils.signal_processing import denormalize_signals, normalize_signal
 from recipes.cad1.task1.baseline.evaluate import make_song_listener_list
 
@@ -146,8 +147,7 @@ def decompose_signal(
     signal: np.ndarray,
     sample_rate: int,
     device: torch.device,
-    left_audiogram: np.ndarray,
-    right_audiogram: np.ndarray,
+    listener: Listener,
 ) -> dict[str, np.ndarray]:
     """
     Decompose signal into 8 stems.
@@ -163,8 +163,7 @@ def decompose_signal(
         signal (np.ndarray): Signal to be decomposed.
         sample_rate (int): Sample frequency.
         device (torch.device): Torch device to use for processing.
-        left_audiogram (np.ndarray): Left ear audiogram.
-        right_audiogram (np.ndarray): Right ear audiogram.
+        listener (Listener).
 
      Returns:
          Dictionary: Indexed by sources with the associated model as values.
@@ -197,8 +196,7 @@ def apply_baseline_ha(
     enhancer: NALR,
     compressor: Compressor,
     signal: np.ndarray,
-    listener_audiogram: np.ndarray,
-    cfs: np.ndarray,
+    audiogram: Audiogram,
     apply_compressor: bool = False,
 ) -> np.ndarray:
     """
@@ -215,7 +213,8 @@ def apply_baseline_ha(
     Returns:
         An ndarray representing the processed signal.
     """
-    nalr_fir, _ = enhancer.build(listener_audiogram, cfs)
+    print("XXX", audiogram)
+    nalr_fir, _ = enhancer.build(audiogram)
     proc_signal = enhancer.apply(nalr_fir, signal)
     if apply_compressor:
         proc_signal, _, _ = compressor.process(proc_signal)
@@ -226,9 +225,7 @@ def process_stems_for_listener(
     stems: dict,
     enhancer: NALR,
     compressor: Compressor,
-    audiogram_left: np.ndarray,
-    audiogram_right: np.ndarray,
-    cfs: np.ndarray,
+    listener: Listener,
     apply_compressor: bool = False,
 ) -> dict:
     """Process the stems from sources.
@@ -237,8 +234,7 @@ def process_stems_for_listener(
         stems (dict) : Dictionary of stems
         enhancer (NALR) : NAL-R prescription hearing aid
         compressor (Compressor) : Compressor
-        audiogram_left (np.ndarray) : Left channel audiogram
-        audiogram_right (np.ndarray) : Right channel audiogram
+        listener: Listener object
         cfs (np.ndarray) : Center frequencies
         apply_compressor (bool) : Whether to apply the compressor
     Returns:
@@ -251,11 +247,15 @@ def process_stems_for_listener(
         stem_signal = stems[stem_str]
 
         # Determine the audiogram to use
-        audiogram = audiogram_left if stem_str.startswith("l") else audiogram_right
+        audiogram = (
+            listener.audiogram_left
+            if stem_str.startswith("l")
+            else listener.audiogram_right
+        )
 
         # Apply NALR prescription to stem_signal
         proc_signal = apply_baseline_ha(
-            enhancer, compressor, stem_signal, audiogram, cfs, apply_compressor
+            enhancer, compressor, stem_signal, audiogram, apply_compressor
         )
         processed_stems[stem_str] = proc_signal
     return processed_stems
@@ -328,15 +328,14 @@ def enhance(config: DictConfig) -> None:
 
     # Processing Validation Set
     # Load listener audiograms and songs
-    with open(config.path.listeners_valid_file, encoding="utf-8") as file:
-        listener_valid_audiograms = json.load(file)
+    listener_dict = Listener.load_listener_dict(config.path.listeners_valid_file)
 
     with open(config.path.music_valid_file, encoding="utf-8") as file:
         song_data = json.load(file)
     songs_valid = pd.DataFrame.from_dict(song_data)
 
     valid_song_listener_pairs = make_song_listener_list(
-        songs_valid["Track Name"], listener_valid_audiograms
+        songs_valid["Track Name"], listener_dict
     )
     # Select a batch to process
     valid_song_listener_pairs = valid_song_listener_pairs[
@@ -357,7 +356,7 @@ def enhance(config: DictConfig) -> None:
             f"Processing {song_name} for {listener_name}..."
         )
         # Get the listener's audiogram
-        listener_info = listener_valid_audiograms[listener_name]
+        listener = listener_dict[listener_name]
 
         # Find the music split directory
         split_directory = (
@@ -367,10 +366,6 @@ def enhance(config: DictConfig) -> None:
             else "train"
         )
 
-        critical_frequencies = np.array(listener_info["audiogram_cfs"])
-        audiogram_left = np.array(listener_info["audiogram_levels_l"])
-        audiogram_right = np.array(listener_info["audiogram_levels_r"])
-
         # Read the mixture signal
         # Convert to 32-bit floating point and transpose
         # from [samples, channels] to [channels, samples]
@@ -393,8 +388,7 @@ def enhance(config: DictConfig) -> None:
                 mixture_signal,
                 sample_rate,
                 device,
-                audiogram_left,
-                audiogram_right,
+                listener,
             )
 
         # Baseline applies NALR prescription to each stem instead of using the
@@ -404,9 +398,7 @@ def enhance(config: DictConfig) -> None:
             stems,
             enhancer,
             compressor,
-            audiogram_left,
-            audiogram_right,
-            critical_frequencies,
+            listener,
             config.apply_compressor,
         )
 
@@ -421,9 +413,9 @@ def enhance(config: DictConfig) -> None:
 
             filename = (
                 enhanced_folder
-                / f"{listener_info['name']}"
+                / f"{listener.id}"
                 / f"{song_name}"
-                / f"{listener_info['name']}_{song_name}_{stem_str}.wav"
+                / f"{listener.id}_{song_name}_{stem_str}.wav"
             )
             filename.parent.mkdir(parents=True, exist_ok=True)
 
@@ -436,9 +428,9 @@ def enhance(config: DictConfig) -> None:
         enhanced = np.stack([output_left, output_right], axis=1)
         filename = (
             enhanced_folder
-            / f"{listener_info['name']}"
+            / f"{listener.id}"
             / f"{song_name}"
-            / f"{listener_info['name']}_{song_name}_remix.wav"
+            / f"{listener.id}_{song_name}_remix.wav"
         )
 
         # clip and save enhanced signal
diff --git a/recipes/cad1/task1/baseline/evaluate.py b/recipes/cad1/task1/baseline/evaluate.py
index ae883ee6c..469513bc6 100644
--- a/recipes/cad1/task1/baseline/evaluate.py
+++ b/recipes/cad1/task1/baseline/evaluate.py
@@ -9,7 +9,6 @@
 import json
 import logging
 from pathlib import Path
-from typing import Any
 
 import hydra
 import numpy as np
@@ -17,7 +16,9 @@
 from omegaconf import DictConfig
 from scipy.io import wavfile
 
-from recipes.cad1.task1.baseline.haaqi_rms import compute_haaqi_rms
+from clarity.evaluator.haaqi import compute_haaqi
+from clarity.utils.audiogram import Listener
+from clarity.utils.signal_processing import compute_rms
 
 logger = logging.getLogger(__name__)
 
@@ -61,7 +62,7 @@ def write_header(self):
 
     def add_result(
         self,
-        listener: str,
+        listener_id: str,
         song: str,
         score: float,
         instruments_scores: dict[str, float],
@@ -69,7 +70,7 @@ def add_result(
         """Add a result to the CSV file.
 
         Args:
-            listener (str): The name of the listener who submitted the result.
+            listener_id (str): The name of the listener who submitted the result.
             song (str): The name of the song that the result is for.
             score (float): The combined score for the result.
             instruments_scores (dict): A dictionary of scores for each instrument
@@ -84,7 +85,7 @@ def add_result(
             csv_writer.writerow(
                 [
                     song,
-                    listener,
+                    listener_id,
                     str(score),
                     str(instruments_scores["left_bass"]),
                     str(instruments_scores["right_bass"]),
@@ -106,7 +107,7 @@ def set_song_seed(song: str) -> None:
 
 
 def make_song_listener_list(
-    songs: list[str], listeners: dict[str, Any], small_test: bool = False
+    songs: list[str], listeners: dict[str, Listener], small_test: bool = False
 ) -> list[tuple[str, str]]:
     """Make the list of scene-listener pairing to process"""
     song_listener_pairs = list(itertools.product(songs, listeners.keys()))
@@ -119,10 +120,9 @@ def make_song_listener_list(
 
 def _evaluate_song_listener(
     song: str,
-    listener: str,
+    listener: Listener,
     config: DictConfig,
     split_dir: str,
-    listener_audiograms: dict,
     enhanced_folder: Path,
 ) -> tuple[float, dict]:
     """Evaluate a single song-listener pair
@@ -132,7 +132,6 @@ def _evaluate_song_listener(
         listener (str): The name of the listener to evaluate.
         config (DictConfig): The configuration object.
         split_dir (str): The name of the split directory.
-        listener_audiograms (dict): A dictionary of audiograms for each listener.
         enhanced_folder (Path): The path to the folder containing the enhanced signals.
 
     Returns:
@@ -142,7 +141,7 @@ def _evaluate_song_listener(
 
     """
 
-    logger.info(f"Evaluating {song} for {listener}")
+    logger.info(f"Evaluating {song} for {listener.id}")
 
     if config.evaluate.set_random_seed:
         set_song_seed(song)
@@ -166,18 +165,18 @@ def _evaluate_song_listener(
         # Load left channel
         sample_rate_left_enhanced_signal, left_enhanced_signal = wavfile.read(
             enhanced_folder
-            / f"{listener}"
+            / f"{listener.id}"
             / f"{song}"
-            / f"{listener}_{song}_left_{instrument}.wav"
+            / f"{listener.id}_{song}_left_{instrument}.wav"
         )
         left_enhanced_signal = (left_enhanced_signal / 32768.0).astype(np.float32)
 
         # Load right channel
         sample_rate_right_enhanced_signal, right_enhanced_signal = wavfile.read(
             enhanced_folder
-            / f"{listener}"
+            / f"{listener.id}"
             / f"{song}"
-            / f"{listener}_{song}_right_{instrument}.wav"
+            / f"{listener.id}_{song}_right_{instrument}.wav"
         )
         right_enhanced_signal = (right_enhanced_signal / 32768.0).astype(np.float32)
 
@@ -188,22 +187,19 @@ def _evaluate_song_listener(
             == config.sample_rate
         )
 
-        #  audiogram, audiogram_frequencies, fs_signal
-        per_instrument_score[f"left_{instrument}"] = compute_haaqi_rms(
+        per_instrument_score[f"left_{instrument}"] = compute_haaqi(
             left_enhanced_signal,
             reference_signal[:, 0],
-            np.array(listener_audiograms["audiogram_levels_l"]),
-            np.array(listener_audiograms["audiogram_cfs"]),
+            listener.audiogram_left,
             config.sample_rate,
-            silence_length=2.0,
+            65 - 20 * np.log10(compute_rms(reference_signal[:, 0])),
         )
-        per_instrument_score[f"right_{instrument}"] = compute_haaqi_rms(
+        per_instrument_score[f"right_{instrument}"] = compute_haaqi(
             right_enhanced_signal,
             reference_signal[:, 1],
-            np.array(listener_audiograms["audiogram_levels_r"]),
-            np.array(listener_audiograms["audiogram_cfs"]),
+            listener.audiogram_right,
             config.sample_rate,
-            silence_length=2.0,
+            65 - 20 * np.log10(compute_rms(reference_signal[:, 1])),
         )
 
     # Compute the combined score
@@ -221,8 +217,7 @@ def run_calculate_aq(config: DictConfig) -> None:
     songs = pd.DataFrame.from_dict(songs)
 
     # Load listener data
-    with open(config.path.listeners_valid_file, encoding="utf-8") as fp:
-        listener_audiograms = json.load(fp)
+    listener_dict = Listener.load_listener_dict(config.path.listeners_valid_file)
 
     enhanced_folder = Path("enhanced_signals")
     logger.info(f"Evaluating from {enhanced_folder} directory")
@@ -233,28 +228,27 @@ def run_calculate_aq(config: DictConfig) -> None:
     results_file.write_header()
 
     song_listener_pair = make_song_listener_list(
-        songs["Track Name"].tolist(), listener_audiograms, config.evaluate.small_test
+        songs["Track Name"].tolist(), listener_dict, config.evaluate.small_test
     )
 
     song_listener_pair = song_listener_pair[
         config.evaluate.batch :: config.evaluate.batch_size
     ]
 
-    for song, listener in song_listener_pair:
+    for song, listener_id in song_listener_pair:
         split_dir = "train"
         if songs[songs["Track Name"] == song]["Split"].tolist()[0] == "test":
             split_dir = "test"
-
+        listener = listener_dict[listener_id]
         combined_score, per_instrument_score = _evaluate_song_listener(
             song,
             listener,
             config,
             split_dir,
-            listener_audiograms[listener],
             enhanced_folder,
         )
         results_file.add_result(
-            listener,
+            listener.id,
             song,
             score=combined_score,
             instruments_scores=per_instrument_score,
diff --git a/recipes/cad1/task1/baseline/haaqi_rms.py b/recipes/cad1/task1/baseline/haaqi_rms.py
deleted file mode 100644
index 1d4da9659..000000000
--- a/recipes/cad1/task1/baseline/haaqi_rms.py
+++ /dev/null
@@ -1,177 +0,0 @@
-"""HAAQI-RMS metric"""
-from __future__ import annotations
-
-import numpy as np
-from scipy.signal import correlate, correlation_lags
-
-from clarity.evaluator.haaqi import compute_haaqi
-from clarity.utils.signal_processing import compute_rms
-
-
-def compute_haaqi_rms(
-    processed_signal: np.ndarray,
-    reference_signal: np.ndarray,
-    audiogram: np.ndarray,
-    audiogram_frequencies: np.ndarray,
-    sample_rate: int,
-    silence_length: float = 2.0,
-) -> float:
-    """Compute HAAQI-RMS metric
-
-    Metric is a combination of HAAQI and RMS. Signals are split into
-    non-silence and silence segments based on the reference signal.
-    HAAQI is computed on the non-silence parts and RMS is computed
-    on the silence parts.
-
-    The HAAQI part of the metric measure the music quality and the RMS
-    part of the metric punishes bad separations. I.e. if the RMS is high
-    it means that parts that should be silence contains residual or
-    artefact errors.
-
-    Args:
-        processed_signal (np.ndarray): Output signal with noise, distortion, HA gain,
-            and/or processing.
-        reference_signal (np.ndarray): Input reference speech signal with no
-            noise or distortion. If a hearing loss is specified, NAL-R
-            equalization is optional.
-        audiogram (np.ndarray): Vector of hearing loss at the audiogram_frequencies
-        audiogram_frequencies (np.ndarray): Audiogram frequencies
-        sample_rate (int): Sample rate in Hz.
-        silence_length (float): Minimum length of silence in seconds to use
-            for RMS calculation.
-            Segments of silence shorter than this are included in non_silence segments.
-            Defaults to 2.
-
-    Returns:
-        float: HAAQI-RMS metric
-    """
-
-    # align signals
-    processed_signal = align_signals(reference_signal, processed_signal)
-
-    # find silence segments
-    silence, non_silence = find_silence_segments(
-        reference_signal, sample_rate, silence_length
-    )
-
-    # join non-silence segments for processed and reference signals
-    new_processed_signal_list = []
-    new_reference_signal_list = []
-
-    for start, end in non_silence:
-        new_processed_signal_list.append(processed_signal[start:end])
-        new_reference_signal_list.append(reference_signal[start:end])
-
-    if len(new_processed_signal_list) > 1:
-        new_reference_signal = np.concatenate(new_reference_signal_list)
-        new_processed_signal = np.concatenate(new_processed_signal_list)
-    else:
-        new_reference_signal = new_reference_signal_list[0]
-        new_processed_signal = new_processed_signal_list[0]
-
-    # join silence segments for processed signal
-    silence_processed_list = []
-    for start, end in silence:
-        silence_processed_list.append(processed_signal[start:end])
-    silence_processed = np.concatenate(silence_processed_list)
-
-    # Compute haaqi on music segments
-    haaqi_score = compute_haaqi(
-        processed_signal=new_processed_signal,
-        reference_signal=new_reference_signal,
-        sample_rate=sample_rate,
-        audiogram=audiogram,
-        audiogram_frequencies=audiogram_frequencies,
-        equalisation=1,
-        level1=65 - 20 * np.log10(compute_rms(new_reference_signal)),
-        scale_reference=True,
-    )
-
-    # Compute rms on silence segments
-    rms_score = compute_rms(silence_processed)
-
-    return (
-        haaqi_score * len(new_reference_signal) - rms_score * len(silence_processed)
-    ) / (len(new_reference_signal) + len(silence_processed))
-
-
-def align_signals(
-    reference_signal: np.ndarray, processed_signal: np.ndarray
-) -> np.ndarray:
-    """Align processed signal to reference signals
-
-    Args:
-        processed_signal (np.ndarray): Output signal with noise, distortion, HA gain,
-            and/or processing.
-        reference_signal (np.ndarray): Input reference speech signal with no
-            noise or distortion. If a hearing loss is specified, NAL-R
-            equalization is optional.
-    Returns:
-        np.ndarray: Aligned processed signal
-    """
-
-    processed_n = len(processed_signal)
-
-    corr = correlate(processed_signal, reference_signal, mode="same")
-    lags = correlation_lags(processed_signal.size, reference_signal.size, mode="same")
-    delay = lags[np.argmax(corr)]
-
-    if delay > 0:
-        # Output delayed relative to the reference
-        processed_signal = np.concatenate(
-            (processed_signal[delay:processed_n], np.zeros(delay))
-        )
-    else:
-        # Output advanced relative to the reference
-        processed_signal = np.concatenate(
-            (np.zeros(-delay), processed_signal[: processed_n + delay])
-        )
-
-    return processed_signal
-
-
-def find_silence_segments(
-    signal: np.ndarray, sample_rate: int, min_silence_length: float = 1
-) -> tuple[list, list]:
-    """Find silence segments in signal
-
-    Args:
-        signal (np.ndarray): Input signal
-        sample_rate (int): Sample rate in Hz
-        min_silence_length (float): Minimum length of silence in
-            seconds classify silence or non_silence segment.
-            Defaults to 1.
-    Returns:
-        tuple[list, list]: Silence and non-silence segments
-    """
-    # Find the start and end of the noiseless reference sequence
-    reference_abs = np.abs(signal)
-    reference_max = np.max(reference_abs)
-    threshold = 0.001 * reference_max
-
-    # Find silence frames
-    silence_frames = np.where(np.abs(signal) < threshold)[0]
-    silence_groups = np.split(
-        silence_frames, np.where(np.diff(silence_frames) != 1)[0] + 1
-    )
-    silence_segments = [
-        [group[0], group[-1]]
-        for group in silence_groups
-        if len(group) > sample_rate * min_silence_length
-    ]
-    if len(silence_segments) == 0:
-        silence_segments = [[0, 0]]
-
-    # find silence segments
-    non_silence_segments = []
-    for start, end in zip(silence_segments[:-1], silence_segments[1:]):
-        non_silence_segments.append([start[-1] + 1, end[0] - 1])
-
-    # add first and last non-silence segments
-    if len(silence_segments) > 0:
-        if silence_segments[0][0] > 0:
-            non_silence_segments.insert(0, [0, silence_segments[0][0] - 1])
-        if silence_segments[-1][-1] < len(signal) - 1:
-            non_silence_segments.append([silence_segments[-1][-1] + 1, len(signal) - 1])
-
-    return silence_segments, non_silence_segments
diff --git a/recipes/cad1/task2/baseline/baseline_utils.py b/recipes/cad1/task2/baseline/baseline_utils.py
index 58688ba2c..fe67c02f3 100644
--- a/recipes/cad1/task2/baseline/baseline_utils.py
+++ b/recipes/cad1/task2/baseline/baseline_utils.py
@@ -11,6 +11,8 @@
 import pandas as pd
 from omegaconf import DictConfig
 
+from clarity.utils.audiogram import Listener
+
 # pylint: disable=import-error
 
 
@@ -72,7 +74,9 @@ def load_hrtf(config: DictConfig) -> dict:
     return hrtf_data[config.evaluate.split]
 
 
-def load_listeners_and_scenes(config: DictConfig) -> tuple[dict, dict, dict]:
+def load_listeners_and_scenes(
+    config: DictConfig,
+) -> tuple[dict, dict[str, Listener], dict]:
     """Load listener and scene data
 
     Args:
@@ -90,16 +94,15 @@ def load_listeners_and_scenes(config: DictConfig) -> tuple[dict, dict, dict]:
     with open(config.path.scenes_file, encoding="utf-8") as fp:
         df_scenes = pd.read_json(fp, orient="index")
 
-    # Load audiograms and scence data for the corresponding split
+    # Load audiograms and scene data for the corresponding split
     if config.evaluate.split == "train":
-        with open(config.path.listeners_train_file, encoding="utf-8") as fp:
-            listener_audiograms = json.load(fp)
+        listeners = Listener.load_listener_dict(config.path.listeners_train_file)
         scenes = df_scenes[df_scenes["split"] == "train"].to_dict("index")
-
     elif config.evaluate.split == "valid":
-        with open(config.path.listeners_valid_file, encoding="utf-8") as fp:
-            listener_audiograms = json.load(fp)
+        listeners = Listener.load_listener_dict(config.path.listeners_valid_file)
         scenes = df_scenes[df_scenes["split"] == "valid"].to_dict("index")
+    else:
+        raise ValueError(f"Unknown split {config.evaluate.split}")
 
     with open(config.path.scenes_listeners_file, encoding="utf-8") as fp:
         scenes_listeners = json.load(fp)
@@ -107,7 +110,7 @@ def load_listeners_and_scenes(config: DictConfig) -> tuple[dict, dict, dict]:
             k: v for k, v in scenes_listeners.items() if k in scenes.keys()
         }
 
-    return scenes, listener_audiograms, scenes_listeners
+    return scenes, listeners, scenes_listeners
 
 
 def make_scene_listener_list(scenes_listeners, small_test=False):
diff --git a/recipes/cad1/task2/baseline/car_scene_acoustics.py b/recipes/cad1/task2/baseline/car_scene_acoustics.py
index c608dfe6c..9fe8cc3e1 100644
--- a/recipes/cad1/task2/baseline/car_scene_acoustics.py
+++ b/recipes/cad1/task2/baseline/car_scene_acoustics.py
@@ -15,6 +15,7 @@
 
 from clarity.enhancer.compressor import Compressor
 from clarity.enhancer.nalr import NALR
+from clarity.utils.audiogram import Audiogram, Listener
 from clarity.utils.car_noise_simulator.carnoise_signal_generator import (
     CarNoiseSignalGenerator,
 )
@@ -104,9 +105,7 @@ def preload_anechoic_hrtf(self, hrtf_dir: str) -> None:
         for key, item in self.ANECHOIC_HRTF_FOR_NOISE.items():
             self.hrir_for_noise[key] = wavfile.read(anechoic_hrtf_dir / item)[1]
 
-    def apply_hearing_aid(
-        self, signal: np.ndarray, audiogram: np.ndarray, center_frequencies: np.ndarray
-    ) -> np.ndarray:
+    def apply_hearing_aid(self, signal: np.ndarray, audiogram: Audiogram) -> np.ndarray:
         """
         Applies the hearing aid:
         It consists in NALR prescription and Compressor
@@ -120,7 +119,7 @@ def apply_hearing_aid(
         Returns:
             np.ndarray: The enhanced audio signal.
         """
-        nalr_fir, _ = self.enhancer.build(audiogram, center_frequencies)
+        nalr_fir, _ = self.enhancer.build(audiogram)
         signal = self.enhancer.apply(nalr_fir, signal)
         signal, _, _ = self.compressor.process(signal)
         return signal
@@ -303,7 +302,7 @@ def apply_car_acoustics_to_signal(
         self,
         enh_signal: np.ndarray,
         scene: dict,
-        listener: dict,
+        listener: Listener,
         hrtf: dict,
         audio_manager: AudioManager,
         config: DictConfig,
@@ -314,7 +313,7 @@ def apply_car_acoustics_to_signal(
         Args:
             enh_signal (np.ndarray): The enhanced signal to apply the car acoustics to.
             scene (dict): The scene dictionary with the acoustics parameters.
-            listener (dict): The listener dictionary with the audiograms.
+            listener (Listener): The listener characteristics.
             hrtf (dict): A dictionary containing the head-related transfer functions
                 (HRTFs) for the listener being evaluated. This includes the left and
                 right HRTFs for the car and the anechoic room.
@@ -371,15 +370,11 @@ def apply_car_acoustics_to_signal(
 
         # 5. Apply Hearing Aid to Left and Right channels and join them
         processed_signal_left = self.apply_hearing_aid(
-            processed_signal[0, :],
-            np.array(listener["audiogram_levels_l"]),
-            np.array(listener["audiogram_cfs"]),
+            processed_signal[0, :], listener.audiogram_left
         )
 
         processed_signal_right = self.apply_hearing_aid(
-            processed_signal[1, :],
-            np.array(listener["audiogram_levels_r"]),
-            np.array(listener["audiogram_cfs"]),
+            processed_signal[1, :], listener.audiogram_right
         )
 
         processed_signal = np.stack(
diff --git a/recipes/cad1/task2/baseline/enhance.py b/recipes/cad1/task2/baseline/enhance.py
index 717e30de5..eda3ac47e 100644
--- a/recipes/cad1/task2/baseline/enhance.py
+++ b/recipes/cad1/task2/baseline/enhance.py
@@ -15,6 +15,7 @@
 from scipy.io import wavfile
 from tqdm import tqdm
 
+from clarity.utils.audiogram import Listener
 from recipes.cad1.task2.baseline.baseline_utils import (
     make_scene_listener_list,
     read_mp3,
@@ -24,7 +25,7 @@
 logger = logging.getLogger(__name__)
 
 
-def compute_average_hearing_loss(listener: dict) -> float:
+def compute_average_hearing_loss(listener: Listener) -> float:
     """
     Compute the average hearing loss of a listener.
 
@@ -35,23 +36,15 @@ def compute_average_hearing_loss(listener: dict) -> float:
         average_hearing_loss (float): The average hearing loss of the listener.
 
     """
-    CFS: Final = (500, 1000, 2000, 4000)
-    left_loss = [
-        listener["audiogram_levels_l"][i]
-        for i in range(len(listener["audiogram_cfs"]))
-        if listener["audiogram_cfs"][i] in CFS
-    ]
-    right_loss = [
-        listener["audiogram_levels_r"][i]
-        for i in range(len(listener["audiogram_cfs"]))
-        if listener["audiogram_cfs"][i] in CFS
-    ]
-    return max(float(np.mean(left_loss)), float(np.mean(right_loss)))
+    CFS: Final = np.array([500, 1000, 2000, 4000])
+    left_levels = listener.audiogram_left.resample(CFS).levels
+    right_levels = listener.audiogram_right.resample(CFS).levels
+    return max(float(np.mean(left_levels)), float(np.mean(right_levels)))
 
 
 def enhance_song(
     waveform: np.ndarray,
-    listener_audiograms: dict,
+    listener: Listener,
     config: DictConfig,
 ) -> tuple[np.ndarray, np.ndarray]:
     """
@@ -63,7 +56,7 @@ def enhance_song(
 
     Args:
         waveform (np.ndarray): The waveform of the song.
-        listener_audiograms (dict): The audiograms of the listener.
+        listener_dict (dict): The audiograms of the listener.
         config (dict): Dictionary of configuration options for enhancing music.
 
     Returns:
@@ -74,7 +67,7 @@ def enhance_song(
     meter = pyln.Meter(config.sample_rate)
     original_loudness = meter.integrated_loudness(waveform.T)
 
-    average_hearing_loss = compute_average_hearing_loss(listener_audiograms)
+    average_hearing_loss = compute_average_hearing_loss(listener)
 
     extra_reduction = (
         (average_hearing_loss - 50) / 5 if (average_hearing_loss - 50) / 5 > 0 else 0
@@ -109,7 +102,7 @@ def enhance(config: DictConfig) -> None:
     enhanced_folder = Path("enhanced_signals") / config.evaluate.split
 
     # Load scenes and listeners depending on config.evaluate.split
-    scenes, listener_audiograms, scenes_listeners = load_listeners_and_scenes(config)
+    scenes, listener_dict, scenes_listeners = load_listeners_and_scenes(config)
     scene_listener_pairs = make_scene_listener_list(
         scenes_listeners, config.evaluate.small_test
     )
@@ -119,20 +112,20 @@ def enhance(config: DictConfig) -> None:
 
     for scene_id, listener_id in tqdm(scene_listener_pairs):
         current_scene = scenes[scene_id]
-        listener = listener_audiograms[listener_id]
+        listener = listener_dict[listener_id]
 
         song_path = Path(config.path.music_dir) / f"{current_scene['song_path']}"
 
         # Read song
         song_waveform, _ = read_mp3(song_path, config.sample_rate)
         out_l, out_r = enhance_song(
-            waveform=song_waveform, listener_audiograms=listener, config=config
+            waveform=song_waveform, listener=listener, config=config
         )
 
         enhanced = np.stack([out_l, out_r], axis=1)
-        filename = f"{scene_id}_{listener['name']}_{current_scene['song']}.wav"
+        filename = f"{scene_id}_{listener.id}_{current_scene['song']}.wav"
 
-        enhanced_folder_listener = enhanced_folder / f"{listener['name']}"
+        enhanced_folder_listener = enhanced_folder / f"{listener.id}"
         enhanced_folder_listener.mkdir(parents=True, exist_ok=True)
 
         # Clip and save
diff --git a/recipes/cad1/task2/baseline/evaluate.py b/recipes/cad1/task2/baseline/evaluate.py
index 5137d3ac4..b4413ef9f 100644
--- a/recipes/cad1/task2/baseline/evaluate.py
+++ b/recipes/cad1/task2/baseline/evaluate.py
@@ -15,6 +15,7 @@
 from tqdm import tqdm
 
 from clarity.evaluator.haaqi import compute_haaqi
+from clarity.utils.audiogram import Listener
 from recipes.cad1.task2.baseline.audio_manager import AudioManager
 from recipes.cad1.task2.baseline.baseline_utils import (
     load_hrtf,
@@ -119,7 +120,7 @@ def evaluate_scene(
     sample_rate: int,
     scene_id: str,
     current_scene: dict,
-    listener_audiogram: dict,
+    listener: Listener,
     car_scene_acoustic: CarSceneAcoustics,
     hrtf: dict,
     config: DictConfig,
@@ -136,9 +137,7 @@ def evaluate_scene(
         current_scene (dict): A dictionary containing information about the scene being
             evaluated, including the song ID, the listener ID, the car noise type, and
             the split.
-        listener_audiogram (dict): A dictionary containing the listener's audiogram
-            data, including the center frequencies (cfs) and the hearing levels for both
-            ears (audiogram_levels_l and audiogram_levels_r).
+        listener (Listener): the listener to use
         car_scene_acoustic (CarSceneAcoustics): An instance of the CarSceneAcoustics
             class, which is used to generate car noise and add binaural room impulse
             responses (BRIRs) to the enhanced signal.
@@ -156,7 +155,7 @@ def evaluate_scene(
     """
     audio_manager = AudioManager(
         output_audio_path=Path("evaluation_signals")
-        / f"{listener_audiogram['name']}"
+        / f"{listener.id}"
         / f"{current_scene['song']}",
         sample_rate=sample_rate,
         soft_clip=config.soft_clip,
@@ -170,7 +169,7 @@ def evaluate_scene(
     processed_signal = car_scene_acoustic.apply_car_acoustics_to_signal(
         enh_signal,
         current_scene,
-        listener_audiogram,
+        listener,
         hrtf,
         audio_manager,
         config,
@@ -197,15 +196,13 @@ def evaluate_scene(
     aq_score_l = compute_haaqi(
         processed_signal[0, :],
         ref_signal[0, :],
-        np.array(listener_audiogram["audiogram_levels_l"]),
-        np.array(listener_audiogram["audiogram_cfs"]),
+        listener.audiogram_left,
         sample_rate,
     )
     aq_score_r = compute_haaqi(
         processed_signal[1, :],
         ref_signal[1, :],
-        np.array(listener_audiogram["audiogram_levels_r"]),
-        np.array(listener_audiogram["audiogram_cfs"]),
+        listener.audiogram_right,
         sample_rate,
     )
     return aq_score_l, aq_score_r
@@ -216,7 +213,7 @@ def run_calculate_audio_quality(config: DictConfig) -> None:
     """Evaluate the enhanced signals using the HAAQI metric."""
 
     # Load scenes and listeners depending on config.evaluate.split
-    scenes, listener_audiograms, scenes_listeners = load_listeners_and_scenes(config)
+    scenes, listener_dict, scenes_listeners = load_listeners_and_scenes(config)
     scene_listener_pairs = make_scene_listener_list(
         scenes_listeners, config.evaluate.small_test
     )
@@ -249,7 +246,7 @@ def run_calculate_audio_quality(config: DictConfig) -> None:
         current_scene = scenes[scene_id]
 
         # Retrieve audiograms
-        listener = listener_audiograms[listener_id]
+        listener = listener_dict[listener_id]
 
         # Retrieve HRTF according to the listener's head orientation
         hrtf_scene = hrtfs[str(current_scene["hr"])]
@@ -265,9 +262,9 @@ def run_calculate_audio_quality(config: DictConfig) -> None:
 
         # Load enhanced signal
         enhanced_folder = Path("enhanced_signals") / config.evaluate.split
-        enhanced_song_id = f"{scene_id}_{listener['name']}_{current_scene['song']}"
+        enhanced_song_id = f"{scene_id}_{listener.id}_{current_scene['song']}"
         enhanced_song_path = (
-            enhanced_folder / f"{listener['name']}" / f"{enhanced_song_id}.wav"
+            enhanced_folder / f"{listener.id}" / f"{enhanced_song_id}.wav"
         )
 
         # Read WAV enhanced signal using scipy.io.wavfile
@@ -294,7 +291,7 @@ def run_calculate_audio_quality(config: DictConfig) -> None:
             scene_id,
             current_scene["song"],
             current_scene["song_path"].split("/")[-2],
-            listener_id,
+            listener.id,
             score=float(score),
             haaqi_left=aq_score_l,
             haaqi_right=aq_score_r,
diff --git a/recipes/cec1/baseline/enhance.py b/recipes/cec1/baseline/enhance.py
index 0ed7aa764..595c4833d 100644
--- a/recipes/cec1/baseline/enhance.py
+++ b/recipes/cec1/baseline/enhance.py
@@ -2,12 +2,11 @@
 from pathlib import Path
 
 import hydra
-import numpy as np
 from omegaconf import DictConfig
 from tqdm import tqdm
 
-from clarity.enhancer.gha.audiogram import Audiogram
 from clarity.enhancer.gha.gha_interface import GHAHearingAid
+from clarity.utils.audiogram import Listener
 
 
 @hydra.main(config_path=".", config_name="config")
@@ -16,24 +15,12 @@ def enhance(cfg: DictConfig) -> None:
     enhanced_folder.mkdir(parents=True, exist_ok=True)
     with open(cfg.path.scenes_listeners_file, encoding="utf-8") as fp:
         scenes_listeners = json.load(fp)
-    with open(cfg.path.listeners_file, encoding="utf-8") as fp:
-        listener_audiograms = json.load(fp)
-
+    listener_dict = Listener.load_listener_dict(cfg.path.listeners_file)
     enhancer = GHAHearingAid(**cfg["GHAHearingAid"])
 
     for scene in tqdm(scenes_listeners):
-        for listener in scenes_listeners[scene]:
-            # retrieve audiograms
-            cfs = np.array(listener_audiograms[listener]["audiogram_cfs"])
-            audiogram_left = np.array(
-                listener_audiograms[listener]["audiogram_levels_l"]
-            )
-            audiogram_right = np.array(
-                listener_audiograms[listener]["audiogram_levels_r"]
-            )
-            audiogram = Audiogram(
-                cfs=cfs, levels_l=audiogram_left, levels_r=audiogram_right
-            )
+        for listener_id in scenes_listeners[scene]:
+            listener = listener_dict[listener_id]
 
             infile_names = [
                 f"{cfg.path.scenes_folder}/{scene}_mixed_CH{ch}.wav"
@@ -42,8 +29,7 @@ def enhance(cfg: DictConfig) -> None:
 
             enhancer.process_files(
                 infile_names=infile_names,
-                outfile_name=f"{enhanced_folder}/{scene}_{listener}_HA-output.wav",
-                audiogram=audiogram,
+                outfile_name=f"{enhanced_folder}/{scene}_{listener_id}_HA-output.wav",
                 listener=listener,
             )
 
diff --git a/recipes/cec1/baseline/evaluate.py b/recipes/cec1/baseline/evaluate.py
index 24725fecd..5e6b66e1c 100644
--- a/recipes/cec1/baseline/evaluate.py
+++ b/recipes/cec1/baseline/evaluate.py
@@ -5,29 +5,32 @@
 
 import hydra
 import numpy as np
+from numpy import ndarray
 from omegaconf import DictConfig
 from scipy.signal import unit_impulse
 from tqdm import tqdm
 
 from clarity.evaluator.mbstoi.mbstoi import mbstoi
 from clarity.evaluator.mbstoi.mbstoi_utils import find_delay_impulse
-from clarity.evaluator.msbg.audiogram import Audiogram
 from clarity.evaluator.msbg.msbg import Ear
-from clarity.evaluator.msbg.msbg_utils import MSBG_FS, pad, read_signal, write_signal
+from clarity.evaluator.msbg.msbg_utils import MSBG_FS, pad
+from clarity.utils.audiogram import AUDIOGRAM_REF_CLARITY, Listener
+from clarity.utils.file_io import read_signal, write_signal
 
 
-def listen(ear, signal, audiogram_l, audiogram_r):
+def listen(ear, signal: ndarray, listener: Listener):
     """
     Generate MSBG processed signal
     :param ear: MSBG ear
     :param wav: binaural signal
     :return: binaural signal
     """
-    ear.set_audiogram(audiogram_l)
+    ear.set_audiogram(listener.audiogram_left)
     out_l = ear.process(signal[:, 0])
 
-    ear.set_audiogram(audiogram_r)
+    ear.set_audiogram(listener.audiogram_right)
     out_r = ear.process(signal[:, 1])
+
     if len(out_l[0]) != len(out_r[0]):
         diff = len(out_l[0]) - len(out_r[0])
         if diff > 0:
@@ -43,23 +46,26 @@ def run_HL_processing(cfg: DictConfig) -> None:
     output_path.mkdir(parents=True, exist_ok=True)
     with open(cfg.path.scenes_listeners_file, encoding="utf-8") as fp:
         scenes_listeners = json.load(fp)
-    with open(cfg.path.listeners_file, encoding="utf-8") as fp:
-        listener_audiograms = json.load(fp)
+    listener_dict = Listener.load_listener_dict(cfg.path.listeners_file)
     enhanced_folder = Path(cfg.path.enhanced_signals)
 
     # initialize ear
     ear = Ear(**cfg.MSBGEar)
 
+    # Make reference listener with flat audiogram
+    listener_ref = Listener(AUDIOGRAM_REF_CLARITY, AUDIOGRAM_REF_CLARITY)
+
     for scene in tqdm(scenes_listeners):
-        for listener in scenes_listeners[scene]:
+        for listener_id in scenes_listeners[scene]:
             if enhanced_folder.exists():
-                signal_file = enhanced_folder / f"{scene}_{listener}_HA-output.wav"
+                signal_file = enhanced_folder / f"{scene}_{listener_id}_HA-output.wav"
             # if no enhanced signals, use the unprocessed signal for si calculation
             else:
                 signal_file = Path(cfg.path.scenes_folder) / f"{scene}_mixed_CH0.wav"
+            listener = listener_dict[listener_id]
 
             # signals to write
-            outfile_stem = f"{output_path}/{scene}_{listener}"
+            outfile_stem = f"{output_path}/{scene}_{listener_id}"
             signal_files_to_write = [
                 f"{output_path}/{scene}_flat0dB_HL-output.wav",
                 f"{outfile_stem}_HL-output.wav",
@@ -75,30 +81,16 @@ def run_HL_processing(cfg: DictConfig) -> None:
                 Path(cfg.path.scenes_folder) / f"{scene}_mixed_CH0.wav"
             )
 
-            # retrieve audiograms
-            cfs = np.array(listener_audiograms[listener]["audiogram_cfs"])
-            audiogram_left = np.array(
-                listener_audiograms[listener]["audiogram_levels_l"]
-            )
-            left_audiogram = Audiogram(cfs=cfs, levels=audiogram_left)
-            audiogram_right = np.array(
-                listener_audiograms[listener]["audiogram_levels_r"]
-            )
-            right_audiogram = Audiogram(cfs=cfs, levels=audiogram_right)
-
             # Create discrete delta function (DDF) signal for time alignment
             ddf_signal = np.zeros(np.shape(signal))
             ddf_signal[:, 0] = unit_impulse(len(signal), int(MSBG_FS / 2))
             ddf_signal[:, 1] = unit_impulse(len(signal), int(MSBG_FS / 2))
 
-            # Get flat-0dB ear audiograms
-            flat0dB_audiogram = Audiogram(cfs=cfs, levels=np.zeros(np.shape(cfs)))
-
             signals_to_write = [
-                listen(ear, ddf_signal, flat0dB_audiogram, flat0dB_audiogram),
-                listen(ear, signal, left_audiogram, right_audiogram),
-                listen(ear, ddf_signal, left_audiogram, right_audiogram),
-                listen(ear, mixture_signal, left_audiogram, right_audiogram),
+                listen(ear, ddf_signal, listener_ref),
+                listen(ear, signal, listener),
+                listen(ear, ddf_signal, listener),
+                listen(ear, mixture_signal, listener),
             ]
 
             for signal, out_signal_file in zip(signals_to_write, signal_files_to_write):
diff --git a/recipes/cec1/e009_sheffield/train.py b/recipes/cec1/e009_sheffield/train.py
index 68e8785bd..56b91b636 100644
--- a/recipes/cec1/e009_sheffield/train.py
+++ b/recipes/cec1/e009_sheffield/train.py
@@ -106,7 +106,7 @@ def train_den(cfg, ear):
         den_module.down_sample = torchaudio.transforms.Resample(
             orig_freq=cfg.sample_rate,
             new_freq=cfg.sample_rate // cfg.downsample_factor,
-            resampling_method="sinc_interpolation",
+            resampling_method="sinc_interp_hann",
         )
 
     # callbacks
@@ -177,12 +177,12 @@ def train_amp(cfg, ear):
         amp_module.down_sample = torchaudio.transforms.Resample(
             orig_freq=cfg.sr,
             new_freq=cfg.sr // cfg.downsample_factor,
-            resampling_method="sinc_interpolation",
+            resampling_method="sinc_interp_hann",
         )
         amp_module.up_sample = torchaudio.transforms.Resample(
             orig_freq=cfg.sr // cfg.downsample_factor,
             new_freq=cfg.sr,
-            resampling_method="sinc_interpolation",
+            resampling_method="sinc_interp_hann",
         )
 
     # build normal hearing and hearing loss ears
diff --git a/recipes/cec2/baseline/enhance.py b/recipes/cec2/baseline/enhance.py
index 252d7790a..99267916c 100644
--- a/recipes/cec2/baseline/enhance.py
+++ b/recipes/cec2/baseline/enhance.py
@@ -10,6 +10,7 @@
 
 from clarity.enhancer.compressor import Compressor
 from clarity.enhancer.nalr import NALR
+from clarity.utils.audiogram import Listener
 
 logger = logging.getLogger(__name__)
 
@@ -20,38 +21,30 @@ def enhance(cfg: DictConfig) -> None:
     enhanced_folder.mkdir(parents=True, exist_ok=True)
     with Path(cfg.path.scenes_listeners_file).open("r", encoding="utf-8") as fp:
         scenes_listeners = json.load(fp)
-    with Path(cfg.path.listeners_file).open("r", encoding="utf-8") as fp:
-        listener_audiograms = json.load(fp)
+    listener_dict = Listener.load_listener_dict(cfg.path.listeners_file)
 
     enhancer = NALR(**cfg.nalr)
     compressor = Compressor(**cfg.compressor)
 
     for scene in tqdm(scenes_listeners):
-        for listener in scenes_listeners[scene]:
-            # retrieve audiograms
-            cfs = np.array(listener_audiograms[listener]["audiogram_cfs"])
-            audiogram_left = np.array(
-                listener_audiograms[listener]["audiogram_levels_l"]
-            )
-            audiogram_right = np.array(
-                listener_audiograms[listener]["audiogram_levels_r"]
-            )
+        for listener_id in scenes_listeners[scene]:
+            listener = listener_dict[listener_id]
 
             sample_rate, signal = wavfile.read(
                 Path(cfg.path.scenes_folder) / f"{scene}_mix_CH1.wav"
             )
             signal = signal / 32768.0
             assert sample_rate == cfg.nalr.sample_rate
-            nalr_fir, _ = enhancer.build(audiogram_left, cfs)
+            nalr_fir, _ = enhancer.build(listener.audiogram_left)
             out_l = enhancer.apply(nalr_fir, signal[:, 0])
 
-            nalr_fir, _ = enhancer.build(audiogram_right, cfs)
+            nalr_fir, _ = enhancer.build(listener.audiogram_right)
             out_r = enhancer.apply(nalr_fir, signal[:, 1])
 
             out_l, _, _ = compressor.process(out_l)
             out_r, _, _ = compressor.process(out_r)
             enhanced = np.stack([out_l, out_r], axis=1)
-            filename = f"{scene}_{listener}_HA-output.wav"
+            filename = f"{scene}_{listener_id}_HA-output.wav"
 
             if cfg.soft_clip:
                 enhanced = np.tanh(enhanced)
diff --git a/recipes/cec2/baseline/evaluate.py b/recipes/cec2/baseline/evaluate.py
index 84771bec0..db56c2239 100644
--- a/recipes/cec2/baseline/evaluate.py
+++ b/recipes/cec2/baseline/evaluate.py
@@ -3,6 +3,7 @@
 import json
 import logging
 from pathlib import Path
+from typing import Dict
 
 import hydra
 import numpy as np
@@ -11,11 +12,12 @@
 from tqdm import tqdm
 
 from clarity.evaluator.haspi import haspi_v2_be
+from clarity.utils.audiogram import Listener
 
 logger = logging.getLogger(__name__)
 
 
-def read_csv_scores(file: Path):
+def read_csv_scores(file: Path) -> Dict[str, float]:
     score_dict = {}
     with file.open("r", encoding="utf-8") as fp:
         reader = csv.reader(fp)
@@ -29,8 +31,8 @@ def read_csv_scores(file: Path):
 def run_calculate_SI(cfg: DictConfig) -> None:
     with Path(cfg.path.scenes_listeners_file).open("r", encoding="utf-8") as fp:
         scenes_listeners = json.load(fp)
-    with Path(cfg.path.listeners_file).open("r", encoding="utf-8") as fp:
-        listener_audiograms = json.load(fp)
+
+    listener_dict = Listener.load_listener_dict(cfg.path.listeners_file)
     Path(cfg.path.exp_folder).mkdir(parents=True, exist_ok=True)
 
     enhanced_folder = Path(cfg.path.exp_folder) / "enhanced_signals"
@@ -56,26 +58,19 @@ def run_calculate_SI(cfg: DictConfig) -> None:
         return
 
     for scene in tqdm(scenes_listeners):
-        for listener in scenes_listeners[scene]:
-            logger.info(f"Running SI calculation: scene {scene}, listener {listener}")
+        for listener_id in scenes_listeners[scene]:
+            logger.info(
+                f"Running SI calculation: scene {scene}, listener {listener_id}"
+            )
             if cfg.evaluate.set_random_seed:
                 scene_md5 = int(hashlib.md5(scene.encode("utf-8")).hexdigest(), 16) % (
                     10**8
                 )
                 np.random.seed(scene_md5)
-
-            # retrieve audiograms
-            cfs = np.array(listener_audiograms[listener]["audiogram_cfs"])
-            audiogram_left = np.array(
-                listener_audiograms[listener]["audiogram_levels_l"]
-            )
-            audiogram_right = np.array(
-                listener_audiograms[listener]["audiogram_levels_r"]
-            )
-
+            listener = listener_dict[listener_id]
             # retrieve signals
             fs_proc, proc = wavfile.read(
-                enhanced_folder / f"{scene}_{listener}_HA-output.wav"
+                enhanced_folder / f"{scene}_{listener_id}_HA-output.wav"
             )
 
             fs_ref_anechoic, ref_anechoic = wavfile.read(
@@ -102,12 +97,10 @@ def run_calculate_SI(cfg: DictConfig) -> None:
                 processed_left=proc[:, 0],
                 processed_right=proc[:, 1],
                 sample_rate=fs_ref_anechoic,
-                audiogram_left=audiogram_left,
-                audiogram_right=audiogram_right,
-                audiogram_frequencies=cfs,
+                listener=listener,
             )
             logger.info(f"The HASPI score is {si}")
-            csv_lines.append([scene, listener, str(si)])
+            csv_lines.append([scene, listener_id, str(si)])
 
             if cfg.evaluate.cal_unprocessed_si:
                 if cfg.evaluate.set_random_seed:
@@ -126,12 +119,10 @@ def run_calculate_SI(cfg: DictConfig) -> None:
                     processed_left=unproc[:, 0],
                     processed_right=unproc[:, 1],
                     sample_rate=fs_ref_anechoic,
-                    audiogram_left=audiogram_left,
-                    audiogram_right=audiogram_right,
-                    audiogram_frequencies=cfs,
+                    listener=listener,
                 )
                 logger.info(f"The unprocessed signal HASPI score is {si_unproc}")
-                unproc_csv_lines.append([scene, listener, str(si_unproc)])
+                unproc_csv_lines.append([scene, listener_id, str(si_unproc)])
 
     with si_file.open("w", encoding="utf-8") as csv_f:
         csv_writer = csv.writer(
diff --git a/recipes/cpc1/baseline/run.py b/recipes/cpc1/baseline/run.py
index 8d1cae7ce..fb084ef0a 100644
--- a/recipes/cpc1/baseline/run.py
+++ b/recipes/cpc1/baseline/run.py
@@ -1,5 +1,5 @@
-import csv
-import json
+import csv  # pylint: disable=ungrouped-imports
+import json  # pylint: disable=ungrouped-imports
 import logging
 from pathlib import Path
 
@@ -11,25 +11,28 @@
 
 from clarity.evaluator.mbstoi.mbstoi import mbstoi
 from clarity.evaluator.mbstoi.mbstoi_utils import find_delay_impulse
-from clarity.evaluator.msbg.audiogram import Audiogram
 from clarity.evaluator.msbg.msbg import Ear
-from clarity.evaluator.msbg.msbg_utils import MSBG_FS, pad, read_signal, write_signal
+from clarity.evaluator.msbg.msbg_utils import MSBG_FS, pad
+from clarity.utils.audiogram import AUDIOGRAM_REF_CLARITY, Listener
+from clarity.utils.file_io import read_signal, write_signal
 
 logger = logging.getLogger(__name__)
 
 
-def listen(ear, signal, audiogram_l, audiogram_r):
+def listen(ear, signal, listener: Listener):
     """
     Generate MSBG processed signal
     :param ear: MSBG ear
-    :param wav: binaural signal
+    :param signal: binaural signal
+    :param listener: listener - the listener characteristics to simulate
     :return: binaural signal
     """
-    ear.set_audiogram(audiogram_l)
+    ear.set_audiogram(listener.audiogram_left)
     out_l = ear.process(signal[:, 0])
 
-    ear.set_audiogram(audiogram_r)
+    ear.set_audiogram(listener.audiogram_right)
     out_r = ear.process(signal[:, 1])
+
     if len(out_l[0]) != len(out_r[0]):
         diff = len(out_l[0]) - len(out_r[0])
         if diff > 0:
@@ -44,8 +47,8 @@ def run_HL_processing(cfg, path):
     output_path.mkdir(parents=True, exist_ok=True)
     with open(path.scenes_file, encoding="utf-8") as fp:
         scenes = json.load(fp)
-    with open(path.listeners_file, encoding="utf-8") as fp:
-        listener_audiograms = json.load(fp)
+    listener_dict = Listener.load_listener_dict(path.listeners_file)
+
     enhanced_folder = Path(path.scenes_folder)
 
     # initialize ear
@@ -53,12 +56,12 @@ def run_HL_processing(cfg, path):
 
     for scene_dict in tqdm(scenes):
         scene = scene_dict["scene"]
-        listener = scene_dict["listener"]
+        listener_id = scene_dict["listener"]
         system = scene_dict["system"]
-        signal_file = enhanced_folder / f"{scene}_{listener}_{system}.wav"
+        signal_file = enhanced_folder / f"{scene}_{listener_id}_{system}.wav"
 
         # signals to write
-        outfile_stem = f"{output_path}/{scene}_{listener}_{system}"
+        outfile_stem = f"{output_path}/{scene}_{listener_id}_{system}"
         signal_files_to_write = [
             f"{output_path}/{scene}_flat0dB_HL-output.wav",
             f"{outfile_stem}_HL-output.wav",
@@ -73,25 +76,18 @@ def run_HL_processing(cfg, path):
         mixture_signal = read_signal(Path(path.ref_folder) / f"{scene}_mixed_CH0.wav")
 
         # retrieve audiograms
-        cfs = np.array(listener_audiograms[listener]["audiogram_cfs"])
-        audiogram_left = np.array(listener_audiograms[listener]["audiogram_levels_l"])
-        left_audiogram = Audiogram(cfs=cfs, levels=audiogram_left)
-        audiogram_right = np.array(listener_audiograms[listener]["audiogram_levels_r"])
-        right_audiogram = Audiogram(cfs=cfs, levels=audiogram_right)
+        listener = listener_dict[listener_id]
 
         # Create discrete delta function (DDF) signal for time alignment
         ddf_signal = np.zeros(np.shape(signal))
         ddf_signal[:, 0] = unit_impulse(len(signal), int(MSBG_FS / 2))
         ddf_signal[:, 1] = unit_impulse(len(signal), int(MSBG_FS / 2))
-
-        # Get flat-0dB ear audiograms
-        flat0dB_audiogram = Audiogram(cfs=cfs, levels=np.zeros(np.shape(cfs)))
-
+        reference_listener = Listener(AUDIOGRAM_REF_CLARITY, AUDIOGRAM_REF_CLARITY)
         signals_to_write = [
-            listen(ear, ddf_signal, flat0dB_audiogram, flat0dB_audiogram),
-            listen(ear, signal, left_audiogram, right_audiogram),
-            listen(ear, ddf_signal, left_audiogram, right_audiogram),
-            listen(ear, mixture_signal, left_audiogram, right_audiogram),
+            listen(ear, ddf_signal, reference_listener),
+            listen(ear, signal, listener),
+            listen(ear, ddf_signal, listener),
+            listen(ear, mixture_signal, listener),
         ]
         for signal, signal_file in zip(signals_to_write, signal_files_to_write):
             write_signal(
diff --git a/recipes/cpc1/e029_sheffield/prepare_data.py b/recipes/cpc1/e029_sheffield/prepare_data.py
index 6da481daa..b14bf109d 100644
--- a/recipes/cpc1/e029_sheffield/prepare_data.py
+++ b/recipes/cpc1/e029_sheffield/prepare_data.py
@@ -12,13 +12,14 @@
 from omegaconf import DictConfig
 from tqdm import tqdm
 
-from clarity.evaluator.msbg.audiogram import Audiogram
 from clarity.evaluator.msbg.msbg import Ear
-from clarity.evaluator.msbg.msbg_utils import MSBG_FS, pad, read_signal, write_signal
+from clarity.evaluator.msbg.msbg_utils import MSBG_FS, pad
+from clarity.utils.audiogram import Listener
+from clarity.utils.file_io import read_signal, write_signal
 
 logger = logging.getLogger(__name__)
 
-targ_fs = 16000
+target_sample_rate = 16000
 
 
 def run_data_split(cfg, track):
@@ -53,17 +54,18 @@ def run_data_split(cfg, track):
         json.dump(scene_dev_list, fp)
 
 
-def listen(ear, signal, audiogram_l, audiogram_r):
+def listen(ear, signal, listener: Listener):
     """
     Generate MSBG processed signal
     :param ear: MSBG ear
     :param wav: binaural signal
+    :param listener: listener object
     :return: binaural signal
     """
-    ear.set_audiogram(audiogram_l)
+    ear.set_audiogram(listener.audiogram_left)
     out_l = ear.process(signal[:, 0])
 
-    ear.set_audiogram(audiogram_r)
+    ear.set_audiogram(listener.audiogram_right)
     out_r = ear.process(signal[:, 1])
     if len(out_l[0]) != len(out_r[0]):
         diff = len(out_l[0]) - len(out_r[0])
@@ -83,47 +85,30 @@ def run_msbg_simulation(cfg, track):
         with file_path.open("r", encoding="utf-8") as fp:
             scenes = json.load(fp)
         if split == "train":
-            file_path = dataset_folder / "metadata/listeners.CPC1_train.json"
-            with file_path.open("r", encoding="utf-8") as fp:
-                listener_audiograms = json.load(fp)
+            file_path = Path(dataset_folder) / "metadata/listeners.CPC1_train.json"
         else:
             file_path = Path(dataset_folder) / "metadata/listeners.CPC1_all.json"
-            with file_path.open("r", encoding="utf-8") as fp:
-                listener_audiograms = json.load(fp)
+        listener_dict = Listener.load_listener_dict(file_path)
 
         # initialize ear
         ear = Ear(**cfg["MSBGEar"])
 
         for scene_dict in tqdm(scenes):
             scene = scene_dict["scene"]
-            listener = scene_dict["listener"]
+            listener_id = scene_dict["listener"]
             system = scene_dict["system"]
-            signal_file = output_path / f"{scene}_{listener}_{system}.wav"
+            signal_file = output_path / f"{scene}_{listener_id}_{system}.wav"
 
             # signals to write
-            outfile_stem = output_path / f"{scene}_{listener}_{system}"
-            signal_files_to_write = [
-                Path(f"{outfile_stem}_HL-output.wav"),
-            ]
+            outfile_stem = output_path / f"{scene}_{listener_id}_{system}"
+            signal_files_to_write = [Path(f"{outfile_stem}_HL-output.wav")]
             # if all signals to write exist, pass
             if all(f.exists() for f in signal_files_to_write):
                 continue
             signal = read_signal(signal_file)
 
-            # retrieve audiograms
-            cfs = np.array(listener_audiograms[listener]["audiogram_cfs"])
-            audiogram_left = np.array(
-                listener_audiograms[listener]["audiogram_levels_l"]
-            )
-            left_audiogram = Audiogram(cfs=cfs, levels=audiogram_left)
-            audiogram_right = np.array(
-                listener_audiograms[listener]["audiogram_levels_r"]
-            )
-            right_audiogram = Audiogram(cfs=cfs, levels=audiogram_right)
-
-            signals_to_write = [
-                listen(ear, signal, left_audiogram, right_audiogram),
-            ]
+            listener = listener_dict[listener_id]
+            signals_to_write = [listen(ear, signal, listener)]
 
             for signal, signal_file in zip(signals_to_write, signal_files_to_write):
                 write_signal(
@@ -192,13 +177,17 @@ def generate_data_split(
 
             utt, orig_fs = sf.read(wav_file)
             utt_16k = resample(
-                np.array(utt).transpose(), orig_sr=orig_fs, target_sr=targ_fs
+                np.array(utt).transpose(), orig_sr=orig_fs, target_sr=target_sample_rate
             ).transpose()
             duration = (
-                len(utt_16k[2 * targ_fs :, 0]) / targ_fs
+                len(utt_16k[2 * target_sample_rate :, 0]) / target_sample_rate
             )  # Get rid of the first two seconds, as there is no speech
-            sf.write(wav_file_left, utt_16k[2 * targ_fs :, 0], targ_fs)
-            sf.write(wav_file_right, utt_16k[2 * targ_fs :, 1], targ_fs)
+            sf.write(
+                wav_file_left, utt_16k[2 * target_sample_rate :, 0], target_sample_rate
+            )
+            sf.write(
+                wav_file_right, utt_16k[2 * target_sample_rate :, 1], target_sample_rate
+            )
 
             csv_lines_left.append(
                 [snt_id, str(duration), str(wav_file_left), spk_id, wrds]
diff --git a/recipes/cpc1/e032_sheffield/prepare_data.py b/recipes/cpc1/e032_sheffield/prepare_data.py
index 93dd5a276..c2bdbb95c 100644
--- a/recipes/cpc1/e032_sheffield/prepare_data.py
+++ b/recipes/cpc1/e032_sheffield/prepare_data.py
@@ -12,13 +12,14 @@
 from omegaconf import DictConfig
 from tqdm import tqdm
 
-from clarity.evaluator.msbg.audiogram import Audiogram
 from clarity.evaluator.msbg.msbg import Ear
-from clarity.evaluator.msbg.msbg_utils import MSBG_FS, pad, read_signal, write_signal
+from clarity.evaluator.msbg.msbg_utils import MSBG_FS, pad
+from clarity.utils.audiogram import Listener
+from clarity.utils.file_io import read_signal, write_signal
 
 logger = logging.getLogger(__name__)
 
-targ_fs = 16000
+target_sample_rate = 16000
 
 
 def run_data_split(cfg, track):
@@ -53,17 +54,18 @@ def run_data_split(cfg, track):
         json.dump(scene_dev_list, fp)
 
 
-def listen(ear, signal, audiogram_l, audiogram_r):
+def listen(ear, signal, listener: Listener):
     """
     Generate MSBG processed signal
     :param ear: MSBG ear
     :param wav: binaural signal
+    :param listener: listener object
     :return: binaural signal
     """
-    ear.set_audiogram(audiogram_l)
+    ear.set_audiogram(listener.audiogram_left)
     out_l = ear.process(signal[:, 0])
 
-    ear.set_audiogram(audiogram_r)
+    ear.set_audiogram(listener.audiogram_right)
     out_r = ear.process(signal[:, 1])
     if len(out_l[0]) != len(out_r[0]):
         diff = len(out_l[0]) - len(out_r[0])
@@ -84,24 +86,21 @@ def run_msbg_simulation(cfg, track):
             scenes = json.load(fp)
         if split == "train":
             file_path = dataset_folder / "metadata/listeners.CPC1_train.json"
-            with file_path.open("r", encoding="utf-8") as fp:
-                listener_audiograms = json.load(fp)
         else:
             file_path = Path(dataset_folder) / "metadata/listeners.CPC1_all.json"
-            with file_path.open("r", encoding="utf-8") as fp:
-                listener_audiograms = json.load(fp)
+        listener_dict = Listener.load_listener_dict(file_path)
 
         # initialize ear
         ear = Ear(**cfg["MSBGEar"])
 
         for scene_dict in tqdm(scenes):
             scene = scene_dict["scene"]
-            listener = scene_dict["listener"]
+            listener_id = scene_dict["listener"]
             system = scene_dict["system"]
-            signal_file = output_path / f"{scene}_{listener}_{system}.wav"
+            signal_file = output_path / f"{scene}_{listener_id}_{system}.wav"
 
             # signals to write
-            outfile_stem = output_path / f"{scene}_{listener}_{system}"
+            outfile_stem = output_path / f"{scene}_{listener_id}_{system}"
             signal_files_to_write = [
                 Path(f"{outfile_stem}_HL-output.wav"),
             ]
@@ -111,19 +110,9 @@ def run_msbg_simulation(cfg, track):
             signal = read_signal(signal_file)
 
             # retrieve audiograms
-            cfs = np.array(listener_audiograms[listener]["audiogram_cfs"])
-            audiogram_left = np.array(
-                listener_audiograms[listener]["audiogram_levels_l"]
-            )
-            left_audiogram = Audiogram(cfs=cfs, levels=audiogram_left)
-            audiogram_right = np.array(
-                listener_audiograms[listener]["audiogram_levels_r"]
-            )
-            right_audiogram = Audiogram(cfs=cfs, levels=audiogram_right)
+            listener = listener_dict[listener_id]
 
-            signals_to_write = [
-                listen(ear, signal, left_audiogram, right_audiogram),
-            ]
+            signals_to_write = [listen(ear, signal, listener)]
 
             for signal, signal_file in zip(signals_to_write, signal_files_to_write):
                 write_signal(
@@ -192,13 +181,17 @@ def generate_data_split(
 
             utt, orig_fs = sf.read(wav_file)
             utt_16k = resample(
-                np.array(utt).transpose(), orig_sr=orig_fs, target_sr=targ_fs
+                np.array(utt).transpose(), orig_sr=orig_fs, target_sr=target_sample_rate
             ).transpose()
             duration = (
-                len(utt_16k[2 * targ_fs :, 0]) / targ_fs
+                len(utt_16k[2 * target_sample_rate :, 0]) / target_sample_rate
             )  # Get rid of the first two seconds, as there is no speech
-            sf.write(wav_file_left, utt_16k[2 * targ_fs :, 0], targ_fs)
-            sf.write(wav_file_right, utt_16k[2 * targ_fs :, 1], targ_fs)
+            sf.write(
+                wav_file_left, utt_16k[2 * target_sample_rate :, 0], target_sample_rate
+            )
+            sf.write(
+                wav_file_right, utt_16k[2 * target_sample_rate :, 1], target_sample_rate
+            )
 
             csv_lines_left.append(
                 [snt_id, str(duration), str(wav_file_left), spk_id, wrds]
diff --git a/recipes/cpc2/baseline/compute_haspi.py b/recipes/cpc2/baseline/compute_haspi.py
index 36d5550a4..6150996b0 100644
--- a/recipes/cpc2/baseline/compute_haspi.py
+++ b/recipes/cpc2/baseline/compute_haspi.py
@@ -13,6 +13,7 @@
 from tqdm import tqdm
 
 from clarity.evaluator.haspi import haspi_v2_be
+from clarity.utils.audiogram import Listener
 from clarity.utils.file_io import read_jsonl, write_jsonl
 
 logger = logging.getLogger(__name__)
@@ -44,12 +45,13 @@ def compute_haspi_for_signal(signal_name: str, path: dict) -> float:
         float: HASPI score
     """
 
-    scene, listener, _ = parse_cec2_signal_name(signal_name)
+    scene, listener_id, _ = parse_cec2_signal_name(signal_name)
 
-    # Retrieve audiograms
-    with open(Path(path["metadata_dir"]) / "listeners.json", encoding="utf-8") as fp:
-        listener_audiograms = json.load(fp)
-        audiogram = listener_audiograms[listener]
+    # Retrieve the listeners
+    listener_dict = Listener.load_listener_dict(
+        Path(path["metadata_dir"]) / "listeners.json"
+    )
+    listener = listener_dict[listener_id]
 
     # Retrieve signals and convert to float32 between -1 and 1
     sr_proc, proc = wavfile.read(Path(path["signal_dir"]) / f"{signal_name}.wav")
@@ -66,9 +68,7 @@ def compute_haspi_for_signal(signal_name: str, path: dict) -> float:
         processed_left=proc[:, 0],
         processed_right=proc[:, 1],
         sample_rate=sr_proc,
-        audiogram_left=audiogram["audiogram_levels_l"],
-        audiogram_right=audiogram["audiogram_levels_r"],
-        audiogram_frequencies=audiogram["audiogram_cfs"],
+        listener=listener,
     )
 
     return haspi_score
diff --git a/recipes/icassp_2023/baseline/enhance.py b/recipes/icassp_2023/baseline/enhance.py
index 0edda9734..7576e1147 100644
--- a/recipes/icassp_2023/baseline/enhance.py
+++ b/recipes/icassp_2023/baseline/enhance.py
@@ -9,6 +9,7 @@
 from scipy.io import wavfile
 from tqdm import tqdm
 
+from clarity.utils.audiogram import Listener
 from recipes.icassp_2023.baseline.evaluate import make_scene_listener_list
 
 logger = logging.getLogger(__name__)
@@ -24,16 +25,14 @@ def enhance(cfg: DictConfig) -> None:
     with open(cfg.path.scenes_listeners_file, encoding="utf-8") as fp:
         scenes_listeners = json.load(fp)
 
-    # pylint: disable=unused-variable
-    with open(cfg.path.listeners_file, encoding="utf-8") as fp:
-        listener_audiograms = json.load(fp)  # noqa: F841
+    listener_dict = Listener.load_listener_dict(cfg.path.listeners_file)
 
     # Make list of all scene listener pairs that will be run
     scene_listener_pairs = make_scene_listener_list(
         scenes_listeners, cfg.evaluate.small_test
     )
 
-    for scene, listener in tqdm(scene_listener_pairs):
+    for scene, listener_id in tqdm(scene_listener_pairs):
         sample_rate, signal = wavfile.read(
             pathlib.Path(cfg.path.scenes_folder) / f"{scene}_mix_CH1.wav"
         )
@@ -41,23 +40,17 @@ def enhance(cfg: DictConfig) -> None:
         # Convert to 32-bit floating point scaled between -1 and 1
         signal = (signal / 32768.0).astype(np.float32)
 
-        # # Audiograms can read like this, but they are not needed for the baseline
-        #
-        # cfs = np.array(listener_audiograms[listener]["audiogram_cfs"])
-        #
-        # audiogram_left = np.array(
-        #    listener_audiograms[listener]["audiogram_levels_l"]
-        # )
-        # audiogram_right = np.array(
-        #    listener_audiograms[listener]["audiogram_levels_r"]
-        # )
+        # pylint: disable=unused-variable
+        listener = listener_dict[listener_id]  # noqa: F841
+
+        # Note: The audiograms are stored in the listener object,
+        # but they are not needed for the baseline
 
         # Baseline just reads the signal from the front microphone pair
         # and write it out as the enhanced signal
-        #
 
         wavfile.write(
-            enhanced_folder / f"{scene}_{listener}_enhanced.wav", sample_rate, signal
+            enhanced_folder / f"{scene}_{listener_id}_enhanced.wav", sample_rate, signal
         )
 
 
diff --git a/recipes/icassp_2023/baseline/evaluate.py b/recipes/icassp_2023/baseline/evaluate.py
index 5454cc1b0..2b84c05e9 100644
--- a/recipes/icassp_2023/baseline/evaluate.py
+++ b/recipes/icassp_2023/baseline/evaluate.py
@@ -7,6 +7,7 @@
 
 import hydra
 import numpy as np
+from numpy import ndarray
 from omegaconf import DictConfig
 from scipy.io import wavfile
 from tqdm import tqdm
@@ -15,15 +16,14 @@
 from clarity.enhancer.nalr import NALR
 from clarity.evaluator.haspi import haspi_v2_be
 from clarity.evaluator.hasqi import hasqi_v2_better_ear
+from clarity.utils.audiogram import Audiogram, Listener
 
 logger = logging.getLogger(__name__)
 
 
-def amplify_signal(signal, bin_audiogram, ear, enhancer, compressor):
-    """Amplify signal for either left (l) or right (r) ear"""
-    cfs = np.array(bin_audiogram["audiogram_cfs"])
-    audiogram = np.array(bin_audiogram[f"audiogram_levels_{ear}"])
-    nalr_fir, _ = enhancer.build(audiogram, cfs)
+def amplify_signal(signal, audiogram: Audiogram, enhancer, compressor):
+    """Amplify signal for a given audiogram"""
+    nalr_fir, _ = enhancer.build(audiogram)
     out = enhancer.apply(nalr_fir, signal)
     out, _, _ = compressor.process(out)
     return out
@@ -36,7 +36,9 @@ def set_scene_seed(scene):
     np.random.seed(scene_md5)
 
 
-def compute_metric(metric, signal, ref, audiogram, sample_rate):
+def compute_metric(
+    metric, signal: ndarray, ref: ndarray, listener: Listener, sample_rate: float
+):
     """Compute HASPI or HASQI metric"""
     score = metric(
         reference_left=ref[:, 0],
@@ -44,9 +46,7 @@ def compute_metric(metric, signal, ref, audiogram, sample_rate):
         processed_left=signal[:, 0],
         processed_right=signal[:, 1],
         sample_rate=sample_rate,
-        audiogram_left=audiogram["audiogram_levels_l"],
-        audiogram_right=audiogram["audiogram_levels_r"],
-        audiogram_frequencies=audiogram["audiogram_cfs"],
+        listener=listener,
     )
     return score
 
@@ -64,7 +64,9 @@ def write_header(self):
             )
             csv_writer.writerow(["scene", "listener", "combined", "haspi", "hasqi"])
 
-    def add_result(self, scene, listener, score, haspi, hasqi):
+    def add_result(
+        self, scene: str, listener: str, score: float, haspi: float, hasqi: float
+    ):
         """Add a result to the CSV file"""
 
         logger.info(f"The combined score is {score} (haspi {haspi}, hasqi {hasqi})")
@@ -99,9 +101,7 @@ def run_calculate_si(cfg: DictConfig) -> None:
     with open(cfg.path.scenes_listeners_file, encoding="utf-8") as fp:
         scenes_listeners = json.load(fp)
 
-    with open(cfg.path.listeners_file, encoding="utf-8") as fp:
-        listener_audiograms = json.load(fp)
-
+    listeners_dict = Listener.load_listener_dict(cfg.path.listeners_file)
     enhancer = NALR(**cfg.nalr)
     compressor = Compressor(**cfg.compressor)
 
@@ -119,8 +119,8 @@ def run_calculate_si(cfg: DictConfig) -> None:
     results_file = ResultsFile("scores.csv")
     results_file.write_header()
 
-    for scene, listener in tqdm(scene_listener_pairs):
-        logger.info(f"Running evaluation: scene {scene}, listener {listener}")
+    for scene, listener_id in tqdm(scene_listener_pairs):
+        logger.info(f"Running evaluation: scene {scene}, listener {listener_id}")
 
         if cfg.evaluate.set_random_seed:
             set_scene_seed(scene)
@@ -128,7 +128,7 @@ def run_calculate_si(cfg: DictConfig) -> None:
         # Read signals
 
         sr_signal, signal = wavfile.read(
-            enhanced_folder / f"{scene}_{listener}_enhanced.wav"
+            enhanced_folder / f"{scene}_{listener_id}_enhanced.wav"
         )
         sr_ref_anechoic, ref_anechoic = wavfile.read(
             scenes_folder / f"{scene}_target_anechoic_CH1.wav"
@@ -142,17 +142,21 @@ def run_calculate_si(cfg: DictConfig) -> None:
         assert sr_ref_anechoic == sr_ref_target == sr_signal == cfg.nalr.sample_rate
 
         # amplify left and right ear signals
-        audiogram = listener_audiograms[listener]
+        listener = listeners_dict[listener_id]
 
-        out_l = amplify_signal(signal[:, 0], audiogram, "l", enhancer, compressor)
-        out_r = amplify_signal(signal[:, 1], audiogram, "r", enhancer, compressor)
+        out_l = amplify_signal(
+            signal[:, 0], listener.audiogram_left, enhancer, compressor
+        )
+        out_r = amplify_signal(
+            signal[:, 1], listener.audiogram_right, enhancer, compressor
+        )
         amplified = np.stack([out_l, out_r], axis=1)
 
         if cfg.soft_clip:
             amplified = np.tanh(amplified)
 
         wavfile.write(
-            amplified_folder / f"{scene}_{listener}_HA-output.wav",
+            amplified_folder / f"{scene}_{listener_id}_HA-output.wav",
             sr_signal,
             amplified.astype(np.float32),
         )
@@ -163,14 +167,14 @@ def run_calculate_si(cfg: DictConfig) -> None:
         rms_anechoic = np.mean(ref_anechoic**2, axis=0) ** 0.5
         ref = ref_anechoic * rms_target / rms_anechoic
 
-        haspi_score = compute_metric(haspi_v2_be, amplified, ref, audiogram, sr_signal)
+        haspi_score = compute_metric(haspi_v2_be, amplified, ref, listener, sr_signal)
         hasqi_score = compute_metric(
-            hasqi_v2_better_ear, amplified, ref, audiogram, sr_signal
+            hasqi_v2_better_ear, amplified, ref, listener, sr_signal
         )
         score = 0.5 * (hasqi_score + haspi_score)
 
         results_file.add_result(
-            scene, listener, score=score, haspi=haspi_score, hasqi=hasqi_score
+            scene, listener_id, score=score, haspi=haspi_score, hasqi=hasqi_score
         )
 
 


Test patch:
diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml
index 8f16fc3d0..53046f142 100644
--- a/.github/workflows/run_tests.yml
+++ b/.github/workflows/run_tests.yml
@@ -17,7 +17,7 @@ jobs:
     strategy:
       matrix:
         os: ["ubuntu-latest"]
-        python-version: ["3.8", "3.9", "3.10"]
+        python-version: ["3.8", "3.9", "3.10", "3.11"]
     steps:
       - uses: actions/checkout@v3
       - name: Set up Python
diff --git a/recipes/cec1/e009_sheffield/test.py b/recipes/cec1/e009_sheffield/test.py
index 3341a84e0..5282c8f46 100644
--- a/recipes/cec1/e009_sheffield/test.py
+++ b/recipes/cec1/e009_sheffield/test.py
@@ -28,12 +28,12 @@ def run(cfg: DictConfig) -> None:
         down_sample = torchaudio.transforms.Resample(
             orig_freq=cfg.sample_rate,
             new_freq=cfg.sample_rate // cfg.downsample_factor,
-            resampling_method="sinc_interpolation",
+            resampling_method="sinc_interp_hann",
         )
         up_sample = torchaudio.transforms.Resample(
             orig_freq=cfg.sample_rate // cfg.downsample_factor,
             new_freq=cfg.sample_rate,
-            resampling_method="sinc_interpolation",
+            resampling_method="sinc_interp_hann",
         )
 
     device = "cuda" if torch.cuda.is_available() else None
diff --git a/tests/data/test_HOA_tools_cec2.py b/tests/data/test_HOA_tools_cec2.py
index cb5c88c92..dbd31f82c 100644
--- a/tests/data/test_HOA_tools_cec2.py
+++ b/tests/data/test_HOA_tools_cec2.py
@@ -36,7 +36,9 @@ def test_convert_a_to_b_format() -> None:
         signal[0, :], signal[1, :], signal[2, :], signal[3, :]
     )
     assert result.shape == (4, 100)
-    assert np.sum(np.abs(signal)) == pytest.approx(208.78666127520782)
+    assert np.sum(np.abs(signal)) == pytest.approx(
+        208.78666127520782, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
 
 
 def test_convert_a_to_b_format_index_error() -> None:
@@ -52,7 +54,9 @@ def test_compute_rotation_matrix() -> None:
 
     # Test for identity matrix
     rot_mat = compute_rotation_matrix(order, np.eye(3))
-    assert rot_mat == pytest.approx(np.eye(rot_mat_dim))
+    assert rot_mat == pytest.approx(
+        np.eye(rot_mat_dim), rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
 
     # Test for 90 degree rotation about x-axis
     rot_mat = compute_rotation_matrix(
@@ -92,7 +96,9 @@ def test_hoa_rotate_no_rotation() -> None:
     hoa_rotator = HOARotator(1, 90)
     result = hoa_rotator.rotate(signal, rotations)
     assert result.shape == (100, 4)
-    assert result == pytest.approx(signal)
+    assert result == pytest.approx(
+        signal, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
 
 
 @pytest.mark.parametrize(
@@ -112,7 +118,9 @@ def test_hoa_rotate(seed: int, expected: float) -> None:
     rotations = np.random.random(20)
     hoa_rotator = HOARotator(1, 90)
     result = hoa_rotator.rotate(signal, rotations)
-    assert np.sum(np.abs(result)) == pytest.approx(expected)
+    assert np.sum(np.abs(result)) == pytest.approx(
+        expected, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
 
 
 @pytest.mark.parametrize(
@@ -128,7 +136,9 @@ def test_centred_element(
 ) -> None:
     """Test for centered_element() function."""
     random_matrix = make_random_matrix(seed)
-    assert centred_element(random_matrix, row, col) == pytest.approx(expected)
+    assert centred_element(random_matrix, row, col) == pytest.approx(
+        expected, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
 
 
 def test_centred_element_index_error(make_random_matrix) -> None:
@@ -162,7 +172,7 @@ def test_P(
         b,
         order,
         rotation_matrices=TypedList([r1_matrix, r2_matrix, r3_matrix]),
-    ) == pytest.approx(expected)
+    ) == pytest.approx(expected, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance)
 
 
 def test_P_index_error(make_random_matrix) -> None:
@@ -197,7 +207,7 @@ def test_U(
         n,
         order,
         rotation_matrices=TypedList([r1_matrix, r2_matrix, r3_matrix]),
-    ) == pytest.approx(expected)
+    ) == pytest.approx(expected, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance)
 
 
 @pytest.mark.parametrize(
@@ -221,7 +231,7 @@ def test_V(
         n,
         order,
         rotation_matrices=TypedList([r1_matrix, r2_matrix, r3_matrix]),
-    ) == pytest.approx(expected)
+    ) == pytest.approx(expected, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance)
 
 
 @pytest.mark.parametrize(
@@ -245,7 +255,7 @@ def test_W(
         n,
         order,
         rotation_matrices=TypedList([r1_matrix, r2_matrix, r3_matrix]),
-    ) == pytest.approx(expected)
+    ) == pytest.approx(expected, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance)
 
 
 @pytest.mark.parametrize(
@@ -282,9 +292,13 @@ def test_compute_band_rotation() -> None:
     new_output, new_rotation_matrices = compute_band_rotation(order, rot_mats, output)
     # output and rotation matrices should be unchanged
     assert new_output.shape == (9, 9)
-    assert new_output == pytest.approx(np.eye(9))
+    assert new_output == pytest.approx(
+        np.eye(9), rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
     for new_rot_mat, expected_rot_mat in zip(new_rotation_matrices, rot_mats):
-        assert new_rot_mat == pytest.approx(expected_rot_mat)
+        assert new_rot_mat == pytest.approx(
+            expected_rot_mat, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+        )
 
 
 @pytest.mark.parametrize(
@@ -305,7 +319,9 @@ def test_compute_band_rotation() -> None:
 def test_dot(A: np.ndarray, B: np.ndarray, expected: np.ndarray) -> None:
     """Test for dot() function."""
     result = dot(A, B)
-    assert result == pytest.approx(expected)
+    assert result == pytest.approx(
+        expected, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
 
 
 # TODO : need examples of hrir_metadata dictionary to be able to write a tests for this
@@ -323,7 +339,9 @@ def test_binaural_mixdown() -> None:
 
     result = binaural_mixdown(signals, hrir, hrir_metadata)
     assert result.shape == (199, 2)
-    assert np.sum(np.abs(result)) == pytest.approx(19647.32682819124)
+    assert np.sum(np.abs(result)) == pytest.approx(
+        19647.32682819124, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
 
 
 def test_ambisonic_convolve() -> None:
@@ -335,7 +353,9 @@ def test_ambisonic_convolve() -> None:
     result = ambisonic_convolve(signal, hoa_irs, order=order)
     # channel
     assert result.shape == (199, (order + 1) ** 2)
-    assert np.sum(np.abs(result)) == pytest.approx(3359.04412270433)
+    assert np.sum(np.abs(result)) == pytest.approx(
+        3359.04412270433, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
 
 
 def test_ambisonic_convolve_error() -> None:
@@ -373,7 +393,9 @@ def test_ambisonic_convolve_error() -> None:
 def test_compute_rms(array: np.ndarray, axis: int, expected: float) -> None:
     """Test for compute_rms() function along both axes."""
     result = compute_rms(input_signal=array, axis=axis)
-    assert result == pytest.approx(expected)
+    assert result == pytest.approx(
+        expected, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
 
 
 def test_equalise_rms_levels() -> None:
@@ -387,8 +409,12 @@ def test_equalise_rms_levels() -> None:
     rms_1 = compute_rms(results[0], axis=0)
     rms_2 = compute_rms(results[1], axis=0)
     rms_3 = compute_rms(results[2], axis=0)
-    assert rms_1[0] == pytest.approx(rms_3[0])
-    assert rms_2[0] == pytest.approx(rms_3[0])
+    assert rms_1[0] == pytest.approx(
+        rms_3[0], rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
+    assert rms_2[0] == pytest.approx(
+        rms_3[0], rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
 
 
 @pytest.mark.parametrize(
@@ -397,7 +423,9 @@ def test_equalise_rms_levels() -> None:
 def test_dB_to_gain(db: float, gain: float) -> None:
     """Test for test_dB_to_gain function."""
     result = dB_to_gain(db)
-    assert result == pytest.approx(gain)
+    assert result == pytest.approx(
+        gain, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
 
 
 @pytest.mark.parametrize(
@@ -438,7 +466,9 @@ def test_smoothstep(
 ) -> None:
     """Test for smoothstep() function."""
     result = smoothstep(x, x_min, x_max, N)
-    assert result == pytest.approx(expected)
+    assert result == pytest.approx(
+        expected, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
 
 
 @pytest.mark.parametrize(
@@ -459,7 +489,9 @@ def test_rotation_control_vector(
     """Test for rotation_control_vector() function."""
 
     result = rotation_control_vector(array_length, start_idx, end_idx)
-    assert result == pytest.approx(expected)
+    assert result == pytest.approx(
+        expected, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
 
 
 @pytest.mark.parametrize(
diff --git a/tests/enhancer/gha/test_audiogram.py b/tests/enhancer/gha/test_audiogram.py
deleted file mode 100644
index 3d7394ed1..000000000
--- a/tests/enhancer/gha/test_audiogram.py
+++ /dev/null
@@ -1,76 +0,0 @@
-"""Tests for enhancer.gha.audiogram module"""
-
-import numpy as np
-import pytest
-
-from clarity.enhancer.gha.audiogram import Audiogram
-
-
-def test_audiogram_init():
-    """test construction of audiogram"""
-    levels_l = np.array([45, 45, 35, 45, 60, 65])
-    levels_r = np.array([45, 45, 35, 45, 60, 65])
-    cfs = np.array([250, 500, 1000, 2000, 4000, 6000])
-    audiogram = Audiogram(levels_l=levels_l, levels_r=levels_r, cfs=cfs)
-    assert np.all(audiogram.levels_l == levels_l)
-    assert np.all(audiogram.levels_r == levels_r)
-    assert np.all(audiogram.cfs == cfs)
-    assert isinstance(audiogram.levels_l, np.ndarray)
-
-
-def test_audiogram_init_error():
-    """test that cannot be constructed with invalid number of arguments"""
-    # Need to disable pylint so that we can test the invalid constructor calls
-    with pytest.raises(TypeError):
-        Audiogram()  # pylint: disable=no-value-for-parameter
-    with pytest.raises(TypeError):
-        Audiogram([1])  # pylint: disable=no-value-for-parameter
-    with pytest.raises(TypeError):
-        Audiogram(3, 4, 5, 6)  # pylint: disable=too-many-function-args
-
-
-@pytest.mark.parametrize(
-    "left, right, cfs, expected",
-    [
-        ([45], [45], [250], ["NOTHING", "NOTHING"]),
-        ([15], [16], [2000], ["NOTHING", "MILD"]),
-        ([35], [36], [2000], ["MILD", "MODERATE"]),
-        ([56], [57], [2000], ["MODERATE", "SEVERE"]),
-        ([90], [90], [8001], ["NOTHING", "NOTHING"]),
-        ([90, 90], [16, 16], [8000, 8001], ["SEVERE", "MILD"]),
-    ],
-)
-def test_severity(left, right, cfs, expected):
-    """test severity calculation"""
-    audiogram = Audiogram(
-        levels_l=np.array(left), levels_r=np.array(right), cfs=np.array(cfs)
-    )
-    assert audiogram.severity == expected
-
-
-def test_select_subset_of_cfs():
-    """test that a subset of cfs can be chosen"""
-    levels_l = np.array([1, 2, 3, 4, 5, 6])
-    levels_r = np.array([11, 12, 13, 14, 15, 16])
-    cfs = np.array([250, 500, 1000, 2000, 4000, 6000])
-    audiogram = Audiogram(levels_l=levels_l, levels_r=levels_r, cfs=cfs)
-
-    # Select a subset of cfs
-    subset_audiogram = audiogram.select_subset_of_cfs([250, 1000, 6000])
-    assert np.all(subset_audiogram.levels_l == np.array([1, 3, 6]))
-    assert np.all(subset_audiogram.levels_r == np.array([11, 13, 16]))
-    assert np.all(subset_audiogram.cfs == np.array([250, 1000, 6000]))
-
-    # Include cfs that don't exist in the requested subset
-    subset_audiogram = audiogram.select_subset_of_cfs([251, 1000, 6001])
-    assert np.all(subset_audiogram.levels_l == np.array([3]))
-    assert np.all(subset_audiogram.levels_r == np.array([13]))
-    assert np.all(subset_audiogram.cfs == np.array([1000]))
-
-    # Include only a cf that doesn't exist in the requested subset
-    subset_audiogram = audiogram.select_subset_of_cfs([999])
-    assert np.all(subset_audiogram.levels_l == np.array([]))
-    assert np.all(subset_audiogram.levels_r == np.array([]))
-    assert np.all(subset_audiogram.cfs == np.array([]))
-    # Severity of an 'empty' audiogram should be NOTHING
-    assert subset_audiogram.severity == ["NOTHING", "NOTHING"]
diff --git a/tests/enhancer/gha/test_gainrule_camfit.py b/tests/enhancer/gha/test_gainrule_camfit.py
index 2a0431071..9dc101558 100644
--- a/tests/enhancer/gha/test_gainrule_camfit.py
+++ b/tests/enhancer/gha/test_gainrule_camfit.py
@@ -2,15 +2,14 @@
 import numpy as np
 import pytest
 
-from clarity.enhancer.gha.audiogram import Audiogram
 from clarity.enhancer.gha.gainrule_camfit import (
     compute_proportion_overlap,
-    freq_interp_sh,
     gainrule_camfit_compr,
     gainrule_camfit_linear,
     gains,
     isothr,
 )
+from clarity.utils.audiogram import Audiogram
 
 
 @pytest.mark.parametrize(
@@ -27,7 +26,9 @@
 )
 def test_compute_proportion_overlap(a1, a2, b1, b2, expected):
     """test that the proportion overlap is computed correctly"""
-    assert compute_proportion_overlap(a1, a2, b1, b2) == pytest.approx(expected)
+    assert compute_proportion_overlap(a1, a2, b1, b2) == pytest.approx(
+        expected, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
 
 
 def test_isothr():
@@ -43,41 +44,15 @@ def test_isothr():
     assert np.all(outputs_1 == outputs_2)  # they should be the same
     assert not np.all(outputs_1 == outputs_3)  # they should be the different
 
-    assert np.sum(outputs_1) == pytest.approx(237.52941176470588)
-    assert np.sum(outputs_2) == pytest.approx(237.52941176470588)
-    assert np.sum(outputs_3) == pytest.approx(235.02941176470588)
-
-
-@pytest.mark.parametrize(
-    "f_in, y_in, f_out, expected_y_out",
-    [
-        ([10.0, 20.0], [2.0, 10.0], [10.0, 20.0], [2.0, 10.0]),
-        ([10.0, 20.0], [2.0, 10.0], [5.0, 15.0, 30.0], [2.0, 6.67970001, 10.0]),
-        ([10.0, 20.0], [2.0, 10.0], [15.0], [6.67970001]),
-        ([10.0, 20.0], [2.0, 10.0], [50.0, 1], [10.0, 2.0]),
-        ([10.0], [2.0], [50.0, 1], [2.0, 2.0]),
-    ],
-)
-def test_freq_inter_sh(f_in, y_in, f_out, expected_y_out):
-    """test that the freq_inter_sh interpolates and extrapolates correctly"""
-    y_out = freq_interp_sh(f_in=np.array(f_in), y_in=np.array(y_in), f=np.array(f_out))
-    y_out = np.squeeze(y_out, axis=0)
-    assert y_out.shape == np.array(f_out).shape
-    assert np.allclose(y_out, np.array(expected_y_out))
-
-
-@pytest.mark.parametrize(
-    "f_in, y_in, f_out, error_type",
-    [
-        ([10.0, 20.0], [2.0], [10.0, 20.0], ValueError),
-        ([10.0, 20.0], [2.0, 10.0, 30.0], [5.0, 15.0, 30.0], ValueError),
-        ([], [], [15.0], IndexError),
-    ],
-)
-def test_freq_inter_sh_error(f_in, y_in, f_out, error_type):
-    """test that the freq_inter_sh fails with invalid inputs"""
-    with pytest.raises(error_type):
-        freq_interp_sh(f_in=np.array(f_in), y_in=np.array(y_in), f=np.array(f_out))
+    assert np.sum(outputs_1) == pytest.approx(
+        237.52941176470588, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
+    assert np.sum(outputs_2) == pytest.approx(
+        237.52941176470588, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
+    assert np.sum(outputs_3) == pytest.approx(
+        235.02941176470588, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
 
 
 def test_gains():
@@ -97,15 +72,16 @@ def test_gains():
         levels=levels,
     )
     assert uncorrected_gains.shape == (n_gains, n_gains)
-    assert np.allclose(uncorrected_gains, expected_outputs)
+    assert uncorrected_gains == pytest.approx(
+        expected_outputs, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
 
 
 def test_gainrule_camfit_linear():
     """test that the linear gain rule runs correctly"""
     audiogram = Audiogram(
-        levels_l=np.array([30, 40, 50, 60, 70, 80]),
-        levels_r=np.array([30, 40, 50, 60, 70, 80]),
-        cfs=np.array([250, 500, 1000, 2000, 4000, 8000]),
+        levels=np.array([30, 40, 50, 60, 70, 80]),
+        frequencies=np.array([250, 500, 1000, 2000, 4000, 8000]),
     )
 
     sFitmodel = {
@@ -113,7 +89,8 @@ def test_gainrule_camfit_linear():
         "levels": [30, 40, 50, 60, 70, 80],
     }
     sGt, noisegate_level, noisegate_slope, insertion_gains = gainrule_camfit_linear(
-        audiogram=audiogram,
+        audiogram_left=audiogram,
+        audiogram_right=audiogram,
         sFitmodel=sFitmodel,
         noisegatelevels=45,
         noisegateslope=1,
@@ -125,18 +102,29 @@ def test_gainrule_camfit_linear():
     assert noisegate_slope.shape == (6, 2)
     assert insertion_gains.shape == (6, 2)
 
-    assert sGt.sum() == pytest.approx(1589.2)
-    assert noisegate_level.sum() == pytest.approx(540.0)
-    assert noisegate_slope.sum() == pytest.approx(12.0)
-    assert insertion_gains.sum() == pytest.approx(284.8)
+    assert sGt.sum() == pytest.approx(
+        1589.2, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
+    assert noisegate_level.sum() == pytest.approx(
+        540.0, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
+    assert noisegate_slope.sum() == pytest.approx(
+        12.0, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
+    assert insertion_gains.sum() == pytest.approx(
+        284.8, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
 
 
 def test_gainrule_camfit_compr():
     """test that the compr gain rule runs correctly"""
-    audiogram = Audiogram(
-        levels_l=np.array([30, 40, 50, 60, 70, 80]),
-        levels_r=np.array([30, 40, 50, 60, 70, 80]),
-        cfs=np.array([250, 500, 1000, 2000, 4000, 8000]),
+    audiogram_left = Audiogram(
+        levels=np.array([30, 40, 50, 60, 70, 80]),
+        frequencies=np.array([250, 500, 1000, 2000, 4000, 8000]),
+    )
+    audiogram_right = Audiogram(
+        levels=np.array([30, 40, 50, 60, 70, 80]),
+        frequencies=np.array([250, 500, 1000, 2000, 4000, 8000]),
     )
 
     sFitmodel = {
@@ -145,7 +133,8 @@ def test_gainrule_camfit_compr():
         "edge_frequencies": [250, 8000],
     }
     sGt, noisegate_levels, noisegate_slope = gainrule_camfit_compr(
-        audiogram=audiogram,
+        audiogram_left=audiogram_left,
+        audiogram_right=audiogram_right,
         sFitmodel=sFitmodel,
         noisegatelevels=45,
         noisegateslope=1,
@@ -157,9 +146,15 @@ def test_gainrule_camfit_compr():
     assert noisegate_levels.shape == (6, 2)
     assert noisegate_slope.shape == (6, 2)
 
-    assert sGt.sum() == pytest.approx(2140.1762291737905)
-    assert noisegate_levels.sum() == pytest.approx(540.0)
-    assert noisegate_slope.sum() == pytest.approx(12.0)
+    assert sGt.sum() == pytest.approx(
+        2140.1762291737905, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
+    assert noisegate_levels.sum() == pytest.approx(
+        540.0, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
+    assert noisegate_slope.sum() == pytest.approx(
+        12.0, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
 
 
 @pytest.mark.parametrize(
@@ -178,9 +173,8 @@ def test_gainrule_camfit_compr_varying_params(
 ):
     """test that the compr gain rule runs correctly for different parameters"""
     audiogram = Audiogram(
-        levels_l=np.array([30, 40, 50, 60, 70, 80]),
-        levels_r=np.array([30, 40, 50, 60, 70, 80]),
-        cfs=np.array([250, 500, 1000, 2000, 4000, 8000]),
+        levels=np.array([30, 40, 50, 60, 70, 80]),
+        frequencies=np.array([250, 500, 1000, 2000, 4000, 8000]),
     )
 
     sFitmodel = {
@@ -189,7 +183,8 @@ def test_gainrule_camfit_compr_varying_params(
         "edge_frequencies": [250, 8000],
     }
     sGt, noisegate_levels, noisegate_slope = gainrule_camfit_compr(
-        audiogram=audiogram,
+        audiogram_left=audiogram,
+        audiogram_right=audiogram,
         sFitmodel=sFitmodel,
         noisegatelevels=ng_levels,
         noisegateslope=ng_slope,
@@ -197,6 +192,12 @@ def test_gainrule_camfit_compr_varying_params(
         max_output_level=max_output_level,
     )
 
-    assert sGt.sum() == pytest.approx(out_sgt)
-    assert noisegate_levels.sum() == pytest.approx(out_ng_level)
-    assert noisegate_slope.sum() == pytest.approx(out_ng_slope)
+    assert sGt.sum() == pytest.approx(
+        out_sgt, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
+    assert noisegate_levels.sum() == pytest.approx(
+        out_ng_level, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
+    assert noisegate_slope.sum() == pytest.approx(
+        out_ng_slope, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
diff --git a/tests/enhancer/gha/test_gha_interface.py b/tests/enhancer/gha/test_gha_interface.py
index 6e6b93d37..59573f163 100644
--- a/tests/enhancer/gha/test_gha_interface.py
+++ b/tests/enhancer/gha/test_gha_interface.py
@@ -4,8 +4,9 @@
 import numpy as np
 import pytest
 
-from clarity.enhancer.gha.audiogram import Audiogram
 from clarity.enhancer.gha.gha_interface import GHAHearingAid
+from clarity.utils.audiogram import Audiogram, Listener
+from clarity.utils.file_io import read_signal, write_signal
 
 
 def setup_ha_and_data(root_path):
@@ -29,9 +30,10 @@ def setup_ha_and_data(root_path):
     ).T  # <- signals are store with channels as columns
 
     for filename in tmp_filenames:
-        gha_hearing_aid.write_signal(
+        write_signal(
             filename,
-            stereo_signal,  # <- signals are store with channels as columns
+            stereo_signal,  # <- signals are stored with channels as columns
+            gha_hearing_aid.sample_rate,
             floating_point=True,
         )
     return gha_hearing_aid, stereo_signal, tmp_filenames
@@ -80,11 +82,10 @@ def test_process_files(mocker, tmp_path):
 
     gha_hearing_aid, _stereo_signal, infile_names = setup_ha_and_data(tmp_path)
 
-    levels_l = np.array([45, 45, 35, 45, 60, 65])
-    levels_r = np.array([45, 45, 35, 45, 60, 65])
-    cfs = np.array([250, 500, 1000, 2000, 4000, 6000])
-    audiogram = Audiogram(levels_l=levels_l, levels_r=levels_r, cfs=cfs)
-
+    levels = np.array([45, 45, 35, 45, 60, 65])
+    frequencies = np.array([250, 500, 1000, 2000, 4000, 6000])
+    audiogram = Audiogram(levels=levels, frequencies=frequencies)
+    listener = Listener(audiogram_left=audiogram, audiogram_right=audiogram)
     # Mock the subprocess.run function as OpenMHA is not installed
     m = mocker.patch("clarity.enhancer.gha.gha_interface.subprocess.run")
 
@@ -92,56 +93,22 @@ def test_process_files(mocker, tmp_path):
     # despite that the fact that the file generation is mocked out
     outfile_name = tmp_path / "output.wav"
 
-    gha_hearing_aid.write_signal(
+    write_signal(
         outfile_name,
         np.array([[-0.1, 0.1], [-0.2, 0.2]]),
+        sample_rate=gha_hearing_aid.sample_rate,
         floating_point=True,
     )
 
     gha_hearing_aid.process_files(
         infile_names=infile_names,
         outfile_name=outfile_name,
-        audiogram=audiogram,
-        listener=None,
+        listener=listener,
     )
     # Check that the subprocess.run function was called
     m.assert_called_once()
 
 
-# TODO: Testing functions that are marked for deduplication
-# Remove this test when the functions are removed
-@pytest.mark.parametrize(
-    "signal, floating_point, strict",
-    [
-        (np.array([1.1, -2.0, 0.0, 44.0, -54.0]), True, True),  # float32
-        (np.array([0.1, -0.2, 0.1, -0.5, 0.99]), False, True),  # int16
-        (np.array([0.1, -0.2, 0.1, -1.00, 0.99]), False, True),  # int16
-        (np.array([0.1, -0.2, 0.1, -1.00, 1.99]), False, False),  # int16
-        (np.array([0.1, -0.2, 0.1, -1.00, 0.99]), False, True),  # int16
-    ],
-)
-def test_write_read_loop(tmp_path, signal, floating_point, strict):
-    """Test write_signal and read_signal"""
-    tmp_filename = tmp_path / "test.wav"
-
-    gha_hearing_aid = GHAHearingAid()
-
-    gha_hearing_aid.write_signal(
-        tmp_filename, signal, floating_point=floating_point, strict=strict
-    )
-    result = gha_hearing_aid.read_signal(tmp_filename)
-
-    # Some precision is lost as convert to int16 and back again
-    assert result.shape == signal.shape
-    # The test where strict is False has overflow which is not caught and hence
-    # reading back the signal has changed
-    if strict:
-        assert result == pytest.approx(signal, abs=1.0 / 16384)
-    else:
-        # Deliberate fail: shows why strict is True is needed
-        assert result != pytest.approx(signal, abs=1.0 / 16384)
-
-
 def test_create_ha_inputs(tmp_path):
     """test that the hearing aid inputs can be created correctly"""
 
@@ -151,7 +118,7 @@ def test_create_ha_inputs(tmp_path):
     gha_hearing_aid.create_HA_inputs(tmp_filenames, str(output_filename))
 
     # Read the output file and check it is correct
-    result = gha_hearing_aid.read_signal(output_filename)
+    result = read_signal(output_filename, sample_rate=gha_hearing_aid.sample_rate)
     assert result.shape == (stereo_signal.shape[0], 4)
     assert np.sum(result) == pytest.approx(np.sum(stereo_signal) * 2.0)
 
diff --git a/tests/enhancer/gha/test_gha_utils.py b/tests/enhancer/gha/test_gha_utils.py
index 524b6e0c3..147391ce7 100644
--- a/tests/enhancer/gha/test_gha_utils.py
+++ b/tests/enhancer/gha/test_gha_utils.py
@@ -4,12 +4,12 @@
 import numpy as np
 import pytest
 
-from clarity.enhancer.gha.audiogram import Audiogram
 from clarity.enhancer.gha.gha_utils import (
     format_gaintable,
     get_gaintable,
     multifit_apply_noisegate,
 )
+from clarity.utils.audiogram import Audiogram
 
 
 @pytest.fixture
@@ -17,16 +17,16 @@ def gaintable():
     """fixture for gaintable"""
     np.set_printoptions(threshold=1000)
     np.random.seed(0)
-    levels_l = np.array([45, 45, 35, 45, 60, 65])
-    levels_r = np.array([45, 45, 35, 45, 60, 65])
-    cfs = np.array([250, 500, 1000, 2000, 4000, 6000])
-    audiogram = Audiogram(levels_l=levels_l, levels_r=levels_r, cfs=cfs)
+    levels = np.array([45, 45, 35, 45, 60, 65])
+    frequencies = np.array([250, 500, 1000, 2000, 4000, 6000])
+    audiogram = Audiogram(levels=levels, frequencies=frequencies)
     noisegate_levels = np.array([38, 38, 36, 37, 32, 26, 23, 22, 8])
     noisegate_slope = 0.0
     cr_level = 0.0
     max_output_level = 100.0
     this_gaintable = get_gaintable(
-        audiogram=audiogram,
+        audiogram_left=audiogram,
+        audiogram_right=audiogram,
         noisegate_levels=noisegate_levels,
         noisegate_slope=noisegate_slope,
         cr_level=cr_level,
@@ -50,9 +50,11 @@ def test_get_gaintable(gaintable):
     }
     # check that the gaintable has the expected values
     assert np.sum(np.array(gaintable["sGt_uncorr"])) == pytest.approx(
-        52476.611733926344
+        52476.611733926344, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
+    assert np.sum(np.array(gaintable["sGt"])) == pytest.approx(
+        45866.4547337617, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
     )
-    assert np.sum(np.array(gaintable["sGt"])) == pytest.approx(45866.4547337617)
 
 
 def test_format_gaintable(gaintable):
@@ -77,7 +79,9 @@ def test_multifit_apply_noisegate(gaintable):
     INITIAL_SGT_SUM = 52476.611733926344
     FINAL_SGT_SUM = 45866.4547337617
     sGt = gaintable["sGt_uncorr"]
-    assert np.sum(sGt) == pytest.approx(INITIAL_SGT_SUM)
+    assert np.sum(sGt) == pytest.approx(
+        INITIAL_SGT_SUM, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
     sFit_model_frequencies = gaintable["frequencies"]
     sFit_model_levels = gaintable["levels"]
     noisegate_levels = gaintable["noisegatelevel"]
@@ -90,5 +94,9 @@ def test_multifit_apply_noisegate(gaintable):
         noisegate_slope,
     )
 
-    assert np.sum(sGt) == pytest.approx(INITIAL_SGT_SUM)
-    assert np.sum(corrected_sGt) == pytest.approx(FINAL_SGT_SUM)
+    assert np.sum(sGt) == pytest.approx(
+        INITIAL_SGT_SUM, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
+    assert np.sum(corrected_sGt) == pytest.approx(
+        FINAL_SGT_SUM, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
diff --git a/tests/enhancer/test_compressor.py b/tests/enhancer/test_compressor.py
index 7cf2661b5..5c8596eae 100644
--- a/tests/enhancer/test_compressor.py
+++ b/tests/enhancer/test_compressor.py
@@ -43,5 +43,7 @@ def test_compressor_process():
 
     assert len(output) == len(signal)
     assert np.all(rms >= 0.0)
-    assert np.sum(rms) == pytest.approx(0.9799197751960967)
+    assert np.sum(rms) == pytest.approx(
+        0.9799197751960967, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
     assert len(comp_ratios) == len(signal)
diff --git a/tests/enhancer/test_nalr.py b/tests/enhancer/test_nalr.py
index 3159599a4..93ad1b83c 100644
--- a/tests/enhancer/test_nalr.py
+++ b/tests/enhancer/test_nalr.py
@@ -3,6 +3,7 @@
 import pytest
 
 from clarity.enhancer.nalr import NALR
+from clarity.utils.audiogram import Audiogram
 
 cfg_nalr = {"nfir": 220, "sample_rate": 44100}
 
@@ -11,30 +12,36 @@
 
 
 @pytest.mark.parametrize(
-    "audiogram, cfs, expected",
+    "levels, cfs, expected",
     [
         (
             np.array([45, 45, 35, 45, 60, 65]),
             np.array([250, 500, 1000, 1500, 4000, 6000]),  # <-- non default cfs
-            29.49383298688706,
+            29.808253441926134,  # was 29.493833... (New behaviour due to log freq axis)
         ),
         (
             np.array([45, 45, 35, 45, 60, 65]),
-            None,
+            np.array([250, 500, 1000, 2000, 4000, 6000]),
             28.846583644263408,
         ),
     ],
 )
-def test_nalr(audiogram: np.ndarray, cfs: np.ndarray, expected: float) -> None:
+def test_nalr(levels: np.ndarray, cfs: np.ndarray, expected: float) -> None:
     """Test that the NALR filter is built correctly."""
     enhancer = NALR(**cfg_nalr)
-    nalr_fir, _ = enhancer.build(audiogram, cfs)
-    assert np.sum(np.abs(nalr_fir)) == pytest.approx(expected)
+    audiogram = Audiogram(levels=levels, frequencies=cfs)
+    nalr_fir, _ = enhancer.build(audiogram)
+    assert np.sum(np.abs(nalr_fir)) == pytest.approx(
+        expected, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
 
 
 def test_nalr_default_cfs() -> None:
     """Test that the NALR filter is the same for the default and standard cfs."""
     enhancer = NALR(**cfg_nalr)
-    nalr_fir1, _ = enhancer.build(np.array([45, 45, 35, 45, 60, 65]))
-    nalr_fir2, _ = enhancer.build(np.array([45, 45, 35, 45, 60, 65]), STANDARD_CFS)
-    assert np.allclose(nalr_fir1, nalr_fir2)
+    nalr_fir1, _ = enhancer.build(
+        Audiogram(np.array([45, 45, 35, 45, 60, 65]), STANDARD_CFS)
+    )
+    assert nalr_fir1 == pytest.approx(
+        nalr_fir1, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
diff --git a/tests/evaluator/haaqi/test_haaqi.py b/tests/evaluator/haaqi/test_haaqi.py
index 04a0a3f19..c38ec5a60 100644
--- a/tests/evaluator/haaqi/test_haaqi.py
+++ b/tests/evaluator/haaqi/test_haaqi.py
@@ -3,6 +3,7 @@
 import pytest
 
 from clarity.evaluator.haaqi import compute_haaqi, haaqi_v1
+from clarity.utils.audiogram import Audiogram
 
 
 def test_haaqi_v1() -> None:
@@ -13,11 +14,14 @@ def test_haaqi_v1() -> None:
     y = np.random.uniform(-1, 1, int(sample_rate * 0.5))
 
     hearing_loss = np.array([45, 45, 35, 45, 60, 65])
+    freqs = np.array([250, 500, 1000, 2000, 4000, 6000])
+    audiogram = Audiogram(levels=hearing_loss, frequencies=freqs)
+
     equalisation_mode = 1
     level1 = 65
 
     score, _, _, _ = haaqi_v1(
-        x, sample_rate, y, sample_rate, hearing_loss, equalisation_mode, level1
+        x, sample_rate, y, sample_rate, audiogram, equalisation_mode, level1
     )
     assert score == pytest.approx(
         0.111290948, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
@@ -25,10 +29,21 @@ def test_haaqi_v1() -> None:
 
 
 @pytest.mark.parametrize(
-    "scale_reference,expected_result",
-    [(False, 0.113759275), (True, 0.114157435)],
+    "levels,freqs,expected_result",
+    [
+        (
+            np.array([10, 20, 30, 40, 50, 60]),
+            np.array([250, 500, 1000, 2000, 4000, 6000]),
+            0.113759275,
+        ),
+        (
+            np.array([10, 20, 40, 50, 60]),
+            np.array([250, 500, 2000, 4000, 6000]),  # missing cfs, requires interp
+            0.113759275,
+        ),
+    ],
 )
-def test_compute_haaqi(scale_reference, expected_result):
+def test_compute_haaqi(levels, freqs, expected_result):
     """Test for compute_haaqi function"""
     np.random.seed(42)
 
@@ -36,17 +51,14 @@ def test_compute_haaqi(scale_reference, expected_result):
     enh_signal = np.random.uniform(-1, 1, int(sample_rate * 0.5))
     ref_signal = np.random.uniform(-1, 1, int(sample_rate * 0.5))
 
-    audiogram = np.array([10, 20, 30, 40, 50, 60])
-    audiogram_frequencies = np.array([250, 500, 1000, 2000, 4000, 6000])
+    audiogram = Audiogram(levels=levels, frequencies=freqs)
 
     # Compute HAAQI score
     score = compute_haaqi(
         processed_signal=enh_signal,
         reference_signal=ref_signal,
         audiogram=audiogram,
-        audiogram_frequencies=audiogram_frequencies,
         sample_rate=sample_rate,
-        scale_reference=scale_reference,
     )
 
     # Check that the score is a float between 0 and 1
diff --git a/tests/evaluator/haspi/test_eb.py b/tests/evaluator/haspi/test_eb.py
index eff22e2f6..be375e51c 100644
--- a/tests/evaluator/haspi/test_eb.py
+++ b/tests/evaluator/haspi/test_eb.py
@@ -404,13 +404,17 @@ def test_basilar_membrane_add_noise():
     assert np.sum(np.abs(noisy_reference)) == pytest.approx(
         298.919051930547, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
     )
-    assert not np.allclose(noisy_reference, ref)
+    assert not noisy_reference == pytest.approx(
+        ref, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
 
     # Check that adding on nearly 0 noise (-100 db) doesn't change the signal
     noisy_reference = basilar_membrane_add_noise(
         reference=ref, threshold=-100, level1=120
     )
-    assert np.allclose(noisy_reference, ref)
+    assert noisy_reference == pytest.approx(
+        ref, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
 
 
 # group_delay_compensate
@@ -503,8 +507,12 @@ def test_mel_cepstrum_correlation():
     )
 
     # check shapes and values
-    assert ave_cepstral_correlation == pytest.approx(1.0)
-    assert individual_cepstral_correlation == pytest.approx(1.0)
+    assert ave_cepstral_correlation == pytest.approx(
+        1.0, rel=1e-6, abs=1e-6  # <-- needed to make this a bit looser
+    )
+    assert individual_cepstral_correlation == pytest.approx(
+        1.0, rel=1e-6, abs=1e-6  # <-- needed to make this a bit looser
+    )
 
     # correlation between two random signals should be low
     (
diff --git a/tests/evaluator/haspi/test_ebm.py b/tests/evaluator/haspi/test_ebm.py
index 3e62c9b62..27b5bf91e 100644
--- a/tests/evaluator/haspi/test_ebm.py
+++ b/tests/evaluator/haspi/test_ebm.py
@@ -126,7 +126,9 @@ def test_add_noise() -> None:
     # Add 0 dB noise should not change the signal
     reference = 100 * np.random.normal(size=(100, 4))
     noisy_reference = add_noise(reference, 0)
-    assert np.allclose(noisy_reference, reference)
+    assert noisy_reference == pytest.approx(
+        reference, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
 
 
 def test_fir_modulation_filter() -> None:
@@ -148,7 +150,9 @@ def test_fir_modulation_filter() -> None:
     assert proc_mod.shape == (4, len(in_center_freqs), 100)
     assert out_center_freqs.shape == in_center_freqs.shape
     # Check return value have expected values
-    assert np.allclose(in_center_freqs, out_center_freqs)
+    assert in_center_freqs == pytest.approx(
+        out_center_freqs, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
     assert np.mean(np.abs(ref_mod)) == pytest.approx(
         14.477262943878237, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
     )
@@ -173,7 +177,9 @@ def test_modulation_cross_correlation() -> None:
         reference_modulation=ref_mod, processed_modulation=ref_mod
     )
     assert cross_corr.shape == (n_mod_filters,)
-    assert cross_corr == pytest.approx(1.0)
+    assert cross_corr == pytest.approx(
+        1.0, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
 
     # Check an expected value for a pair of random signals
     cross_corr = modulation_cross_correlation(
diff --git a/tests/evaluator/haspi/test_haspi.py b/tests/evaluator/haspi/test_haspi.py
index f3d83b174..e1c5b930b 100644
--- a/tests/evaluator/haspi/test_haspi.py
+++ b/tests/evaluator/haspi/test_haspi.py
@@ -3,6 +3,7 @@
 import pytest
 
 from clarity.evaluator.haspi import haspi_v2, haspi_v2_be
+from clarity.utils.audiogram import Audiogram, Listener
 
 
 def test_haspi_v2() -> None:
@@ -13,9 +14,11 @@ def test_haspi_v2() -> None:
     y = np.random.uniform(-1, 1, int(sample_rate * 0.5))
 
     hearing_loss = np.array([45, 45, 35, 45, 60, 65])
+    freqs = np.array([250, 500, 1000, 2000, 4000, 6000])
+    audiogram = Audiogram(levels=hearing_loss, frequencies=freqs)
     level1 = 65
 
-    score, _ = haspi_v2(x, sample_rate, y + x, sample_rate, hearing_loss, level1)
+    score, _ = haspi_v2(x, sample_rate, y + x, sample_rate, audiogram, level1)
     assert score == pytest.approx(
         0.043808448934532965, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
     )
@@ -36,6 +39,12 @@ def test_haspi_v2() -> None:
             np.array([250, 500, 700, 1000, 2000, 4000, 6000]),  # <-- extra cf added
             0.839975335323691,
         ),
+        (
+            np.array([25, 25, 25, 65]),
+            np.array([45, 45, 45, 65]),
+            np.array([250, 500, 2000, 6000]),  # <-- missing cfs, need interp
+            0.8380310721987008,  # <-- note different score, as expected
+        ),
     ],
 )
 def test_haspi_v2_better_ear(hl_left, hl_right, freqs, expected_score) -> None:
@@ -49,38 +58,46 @@ def test_haspi_v2_better_ear(hl_left, hl_right, freqs, expected_score) -> None:
     proc_left = np.random.uniform(-1, 1, int(sample_rate * 0.5))  # i.e. 500 ms of audio
     proc_right = np.random.uniform(-1, 1, int(sample_rate * 0.5))
 
+    audiogram_left = Audiogram(levels=hl_left, frequencies=freqs)
+    audiogram_right = Audiogram(levels=hl_right, frequencies=freqs)
+
+    listener = Listener(audiogram_left=audiogram_left, audiogram_right=audiogram_right)
     score = haspi_v2_be(
         reference_left=ref_left,
         reference_right=ref_right,
         processed_left=proc_left + ref_left,
         processed_right=proc_right,
         sample_rate=sample_rate,
-        audiogram_left=hl_left,
-        audiogram_right=hl_right,
-        audiogram_frequencies=freqs,
+        listener=listener,
         level=100,
     )
 
-    assert score == pytest.approx(expected_score)
+    assert score == pytest.approx(
+        expected_score, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
 
 
 @pytest.mark.parametrize(
-    "hl_left, hl_right, freqs",
+    "hl_left, hl_right, freqs, expected_score",
     [
         (
             np.array([25, 25, 25, 40, 65]),
             np.array([45, 35, 45, 60, 65]),
             np.array([250, 1000, 2000, 4000, 6000]),
+            0.839975335323691,
         ),
         (
             np.array([25, 25, 21, 25, 25, 65]),
             np.array([45, 45, 21, 35, 45, 65]),
             np.array([250, 500, 700, 1000, 2000, 6000]),
+            0.8380310721987008,
         ),
     ],
 )
-def test_haspi_v2_better_ear_error(hl_left, hl_right, freqs) -> None:
-    """Test that haspi throws error if a needed frequency is missing"""
+def test_haspi_v2_better_ear_non_standard(
+    hl_left, hl_right, freqs, expected_score
+) -> None:
+    """Test that haspi works with non standard audiogram frequencies"""
 
     sample_rate = 16000
 
@@ -90,15 +107,19 @@ def test_haspi_v2_better_ear_error(hl_left, hl_right, freqs) -> None:
     proc_left = np.random.uniform(-1, 1, int(sample_rate * 0.5))  # i.e. 500 ms of audio
     proc_right = np.random.uniform(-1, 1, int(sample_rate * 0.5))
 
-    with pytest.raises(ValueError):
-        haspi_v2_be(
-            reference_left=ref_left,
-            reference_right=ref_right,
-            processed_left=proc_left + ref_left,
-            processed_right=proc_right,
-            sample_rate=sample_rate,
-            audiogram_left=hl_left,
-            audiogram_right=hl_right,
-            audiogram_frequencies=freqs,
-            level=100,
-        )
+    audiogram_left = Audiogram(levels=hl_left, frequencies=freqs)
+    audiogram_right = Audiogram(levels=hl_right, frequencies=freqs)
+    listener = Listener(audiogram_left=audiogram_left, audiogram_right=audiogram_right)
+
+    score = haspi_v2_be(
+        reference_left=ref_left,
+        reference_right=ref_right,
+        processed_left=proc_left + ref_left,
+        processed_right=proc_right,
+        sample_rate=sample_rate,
+        listener=listener,
+        level=100,
+    )
+    assert score == pytest.approx(
+        expected_score, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
diff --git a/tests/evaluator/haspi/test_ip.py b/tests/evaluator/haspi/test_ip.py
index 2217521c9..c19d964cc 100644
--- a/tests/evaluator/haspi/test_ip.py
+++ b/tests/evaluator/haspi/test_ip.py
@@ -93,8 +93,12 @@ def test_nn_feed_forward() -> None:
     # Note, it adds a constant 1 to the start of the output and hidden layers
     assert hidden.shape == (neural_net_params["hidden_layer"] + 1,)
     assert output.shape == (neural_net_params["output_layer"] + 1,)
-    assert hidden[0] == pytest.approx(1.0)
-    assert output[0] == pytest.approx(1.0)
+    assert hidden[0] == pytest.approx(
+        1.0, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
+    assert output[0] == pytest.approx(
+        1.0, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
     # Check that the mean of the hidden and output layers are correct
     assert np.mean(hidden) == pytest.approx(
         0.599999906437356, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
diff --git a/tests/evaluator/hasqi/test_hasqi.py b/tests/evaluator/hasqi/test_hasqi.py
index 2fc3659e7..959319479 100644
--- a/tests/evaluator/hasqi/test_hasqi.py
+++ b/tests/evaluator/hasqi/test_hasqi.py
@@ -3,6 +3,7 @@
 import pytest
 
 from clarity.evaluator.hasqi import hasqi_v2, hasqi_v2_better_ear
+from clarity.utils.audiogram import Audiogram, Listener
 
 
 def test_hasqi_v2() -> None:
@@ -13,26 +14,47 @@ def test_hasqi_v2() -> None:
     y = np.random.uniform(-1, 1, int(sample_rate * 0.5))
 
     hearing_loss = np.array([45, 45, 35, 45, 60, 65])
+    freqs = np.array([250, 500, 1000, 2000, 4000, 6000])
+    audiogram = Audiogram(levels=hearing_loss, frequencies=freqs)
+
     equalisation_mode = 1
     level1 = 65
 
     score, _, _, _ = hasqi_v2(
-        x, sample_rate, y, sample_rate, hearing_loss, equalisation_mode, level1
+        x, sample_rate, y, sample_rate, audiogram, equalisation_mode, level1
     )
     assert score == pytest.approx(
         0.002525809, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
     )
 
 
-def test_hasqi_v2_better_ear() -> None:
+@pytest.mark.parametrize(
+    "hl_left, hl_right, freqs, expected_score",
+    [
+        (
+            np.array([25, 25, 25, 25, 40, 65]),
+            np.array([45, 45, 35, 45, 60, 65]),
+            np.array([250, 500, 1000, 2000, 4000, 6000]),
+            0.1256893032667640,
+        ),
+        (
+            np.array([25, 25, 25, 40, 65]),
+            np.array([45, 45, 45, 60, 65]),
+            np.array([250, 500, 2000, 4000, 6000]),  # missing cf, needs interpolation
+            0.1256893032667640,
+        ),
+    ],
+)
+def test_hasqi_v2_better_ear(hl_left, hl_right, freqs, expected_score) -> None:
     """Test for hasqi_v2_better_ear index"""
 
     np.random.seed(0)
     sample_rate = 16000
-    hl_left = np.array([25, 25, 25, 25, 40, 65])
-    hl_right = np.array([45, 45, 35, 45, 60, 65])
 
-    freqs = np.array([250, 500, 1000, 2000, 4000, 6000])
+    audiogram_left = Audiogram(levels=hl_left, frequencies=freqs)
+    audiogram_right = Audiogram(levels=hl_right, frequencies=freqs)
+    listener = Listener(audiogram_left=audiogram_left, audiogram_right=audiogram_right)
+
     ref_left = np.random.uniform(-1, 1, int(sample_rate * 0.5))  # i.e. 500 ms of audio
     ref_right = np.random.uniform(-1, 1, int(sample_rate * 0.5))
     proc_left = np.random.uniform(-1, 1, int(sample_rate * 0.5))  # i.e. 500 ms of audio
@@ -44,13 +66,10 @@ def test_hasqi_v2_better_ear() -> None:
         processed_left=proc_left + ref_left,
         processed_right=proc_right,
         sample_rate=sample_rate,
-        audiogram_left=hl_left,
-        audiogram_right=hl_right,
-        audiogram_frequencies=freqs,
+        listener=listener,
         level=100,
-        audiogram_freq=None,
     )
 
     assert score == pytest.approx(
-        0.1256893032667640, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+        expected_score, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
     )
diff --git a/tests/evaluator/mbstoi/test_mbstoi.py b/tests/evaluator/mbstoi/test_mbstoi.py
index 70d04d46a..c75643f17 100644
--- a/tests/evaluator/mbstoi/test_mbstoi.py
+++ b/tests/evaluator/mbstoi/test_mbstoi.py
@@ -39,4 +39,6 @@ def test_mbstoi(sr_signal, expected_score) -> None:
         dyn_range=60,
     )
 
-    assert mbstoi_val == pytest.approx(expected_score)
+    assert mbstoi_val == pytest.approx(
+        expected_score, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
diff --git a/tests/evaluator/msbg/test_audiogram.py b/tests/evaluator/msbg/test_audiogram.py
deleted file mode 100644
index 4de721e83..000000000
--- a/tests/evaluator/msbg/test_audiogram.py
+++ /dev/null
@@ -1,45 +0,0 @@
-"""Tests for audiogram module"""
-
-import numpy as np
-import pytest
-
-from clarity.evaluator.msbg.audiogram import (
-    AUDIOGRAM_MILD,
-    AUDIOGRAM_MODERATE,
-    AUDIOGRAM_MODERATE_SEVERE,
-    AUDIOGRAM_REF,
-    Audiogram,
-)
-
-
-def test_audiogram():
-    """Test Audiogram class"""
-    levels = np.array([60, 70, 80])
-    cfs = np.array([250, 500, 1000])
-    audiogram = Audiogram(levels=levels, cfs=cfs)
-    assert np.all(audiogram.levels == levels)
-    assert np.all(audiogram.cfs == cfs)
-
-
-@pytest.mark.parametrize(
-    "cfs, levels, severity",
-    [
-        (np.array([500, 1000, 2000, 3000]), np.array([60, 70, 10, 10]), "NOTHING"),
-        (np.array([500, 1000, 2000, 3000]), np.array([60, 70, 20, 20]), "MILD"),
-        (np.array([500, 1000, 2000, 3000]), np.array([60, 70, 40, 40]), "MODERATE"),
-        (np.array([500, 1000, 2000, 3000]), np.array([60, 70, 80, 80]), "SEVERE"),
-        (np.array([500, 800, 900, 1000]), np.array([60, 70, 80, 90]), "NOTHING"),
-        (np.array([9000, 10000]), np.array([120, 120]), "NOTHING"),
-    ],
-)
-def test_audiogram_severity(cfs, levels, severity):
-    """Test Audiogram severity calculation"""
-    assert Audiogram(levels=levels, cfs=cfs).severity == severity
-
-
-def test_standard_audiograms_are_correct_severity():
-    """Test standard audiogram"""
-    assert AUDIOGRAM_REF.severity == "NOTHING"
-    assert AUDIOGRAM_MILD.severity == "MILD"
-    assert AUDIOGRAM_MODERATE.severity == "MODERATE"
-    assert AUDIOGRAM_MODERATE_SEVERE.severity == "SEVERE"
diff --git a/tests/evaluator/msbg/test_cochlea.py b/tests/evaluator/msbg/test_cochlea.py
index 9aa764176..687760748 100644
--- a/tests/evaluator/msbg/test_cochlea.py
+++ b/tests/evaluator/msbg/test_cochlea.py
@@ -3,7 +3,6 @@
 import numpy as np
 import pytest
 
-from clarity.evaluator.msbg.audiogram import Audiogram
 from clarity.evaluator.msbg.cochlea import (
     Cochlea,
     FilterBank,
@@ -12,6 +11,7 @@
     gammatone_filterbank,
     recruitment,
 )
+from clarity.utils.audiogram import Audiogram
 
 
 def test_filterbank():
@@ -46,7 +46,7 @@ def test_compute_recruitment_parameters(center_freqs, cf_expansion_expected):
     levels = np.array([60, 70, 80])
     n_channels = len(levels)
     catch_up = 1.5
-    audiogram = Audiogram(levels=levels, cfs=audiogram_cfs)
+    audiogram = Audiogram(levels=levels, frequencies=audiogram_cfs)
     cf_expansion, eq_loud_db_catch_up = compute_recruitment_parameters(
         gtn_cf=center_freqs,
         audiogram=audiogram,
@@ -54,8 +54,14 @@ def test_compute_recruitment_parameters(center_freqs, cf_expansion_expected):
     )
     assert cf_expansion.shape == (n_channels,)
     assert eq_loud_db_catch_up.shape == (n_channels,)
-    assert cf_expansion == pytest.approx(cf_expansion_expected)
-    assert eq_loud_db_catch_up == pytest.approx(np.array([catch_up] * n_channels))
+    assert cf_expansion == pytest.approx(
+        cf_expansion_expected, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
+    assert eq_loud_db_catch_up == pytest.approx(
+        np.array([catch_up] * n_channels),
+        rel=pytest.rel_tolerance,
+        abs=pytest.abs_tolerance,
+    )
 
 
 def test_gammatone_filterbank():
@@ -65,7 +71,7 @@ def test_gammatone_filterbank():
     signal = np.random.random(1000)
     levels = np.array([30, 40, 40])
     audiogram_cfs = np.array([250, 500, 2000])
-    audiogram = Audiogram(levels=levels, cfs=audiogram_cfs)
+    audiogram = Audiogram(levels=levels, frequencies=audiogram_cfs)
 
     cochlea = Cochlea(audiogram=audiogram)
 
@@ -82,7 +88,9 @@ def test_gammatone_filterbank():
         ),
     )
     assert coch_sig_out.shape == (28, len(signal))
-    assert np.sum(np.abs(coch_sig_out)) == pytest.approx(1327.2811434076052)
+    assert np.sum(np.abs(coch_sig_out)) == pytest.approx(
+        1327.2811434076052, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
 
 
 def test_compute_envelope():
@@ -95,7 +103,9 @@ def test_compute_envelope():
     signal = np.random.random((n_channels, 1000))
     result = compute_envelope(coch_sig=signal, erbn_cf=erbn_cf, fs=sample_rate)
     assert result.shape == (n_channels, 1000)
-    assert np.sum(np.abs(result)) == pytest.approx(1444.2528021105204)
+    assert np.sum(np.abs(result)) == pytest.approx(
+        1444.2528021105204, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
 
 
 def test_recruitment():
@@ -118,7 +128,9 @@ def test_recruitment():
         eq_loud_db=eq_loud_db,
     )
     assert result.shape == (n_channels, 1000)
-    assert np.sum(np.abs(result)) == pytest.approx(1512.7196632453372)
+    assert np.sum(np.abs(result)) == pytest.approx(
+        1512.7196632453372, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
 
 
 @pytest.mark.parametrize(
@@ -133,7 +145,7 @@ def test_recruitment():
 def test_cochlea(levels, severity, has_smearer):
     """Test Cochlea class"""
     audiogram_cfs = np.array([250, 500, 2000])
-    audiogram = Audiogram(levels=levels, cfs=audiogram_cfs)
+    audiogram = Audiogram(levels=levels, frequencies=audiogram_cfs)
     assert audiogram.severity == severity
 
     cochlea = Cochlea(audiogram=audiogram)
@@ -152,9 +164,11 @@ def test_cochlea_simulate(levels, expected, n_samples_out):
     np.random.seed(0)
     signal = np.random.random(1000)
     audiogram_cfs = np.array([250, 500, 2000])
-    audiogram = Audiogram(levels=levels, cfs=audiogram_cfs)
+    audiogram = Audiogram(levels=levels, frequencies=audiogram_cfs)
 
     cochlea = Cochlea(audiogram=audiogram)
     result = cochlea.simulate(coch_sig=signal, equiv_0dB_file_SPL=100.0)
     assert result.shape == (n_samples_out,)
-    assert np.sum(np.abs(result)) == pytest.approx(expected)
+    assert np.sum(np.abs(result)) == pytest.approx(
+        expected, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
diff --git a/tests/evaluator/msbg/test_msbg.py b/tests/evaluator/msbg/test_msbg.py
index e072e6f09..ad0f0e343 100644
--- a/tests/evaluator/msbg/test_msbg.py
+++ b/tests/evaluator/msbg/test_msbg.py
@@ -3,9 +3,9 @@
 import numpy as np
 import pytest
 
-from clarity.evaluator.msbg.audiogram import AUDIOGRAM_MODERATE_SEVERE, Audiogram
 from clarity.evaluator.msbg.msbg import Ear
 from clarity.evaluator.msbg.msbg_utils import DF_ED, FF_ED
+from clarity.utils.audiogram import AUDIOGRAM_MODERATE_SEVERE, Audiogram
 
 
 def test_ear():
@@ -22,7 +22,7 @@ def test_set_audiogram():
     assert ear.cochlea is not None
 
     # Bad audiogram - will log a warning
-    audiogram_bad = Audiogram(levels=np.array([150]), cfs=np.array([4000]))
+    audiogram_bad = Audiogram(levels=np.array([150]), frequencies=np.array([4000]))
     ear.set_audiogram(audiogram=audiogram_bad)
     assert ear.cochlea is not None
 
@@ -34,7 +34,9 @@ def test_get_src_correction():
     correction = Ear.get_src_correction("df")
     assert np.all(correction == DF_ED)
     correction = Ear.get_src_correction("ITU")
-    assert np.sum(correction) == pytest.approx(215.69444085326438)
+    assert np.sum(correction) == pytest.approx(
+        215.69444085326438, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
 
 
 def test_get_src_correction_error():
@@ -54,7 +56,9 @@ def test_src_to_cochlea_filter():
         backward=False,
     )
     assert signal.shape == (1000,)
-    assert np.sum(np.abs(signal)) == pytest.approx(285.70213562623803)
+    assert np.sum(np.abs(signal)) == pytest.approx(
+        285.70213562623803, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
 
 
 @pytest.mark.parametrize("n_channels", [1, 2])
@@ -71,8 +75,14 @@ def test_make_calibration_signal(n_channels):
         n_channels,
         44100 * 0.05,
     )  # 50 ms silence
-    assert np.sum(np.abs(silence)) == pytest.approx(0.0)
-    assert np.sum(np.abs(signal)) == pytest.approx(90853246.9096005 * n_channels)
+    assert np.sum(np.abs(silence)) == pytest.approx(
+        0.0, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
+    assert np.sum(np.abs(signal)) == pytest.approx(
+        90853246.9096005 * n_channels,
+        rel=pytest.rel_tolerance,
+        abs=pytest.abs_tolerance,
+    )
 
 
 def test_ear_process():
@@ -86,34 +96,46 @@ def test_ear_process():
     processed_signals = ear.process(signal)
     assert len(processed_signals) == 1
     assert processed_signals[0].shape == (500,)  # same length as input signal
-    assert np.sum(np.abs(processed_signals[0])) == pytest.approx(0.3250822358794586)
+    assert np.sum(np.abs(processed_signals[0])) == pytest.approx(
+        0.3250822358794586, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
 
     ear.set_audiogram(audiogram=AUDIOGRAM_MODERATE_SEVERE)
 
     processed_signals = ear.process(signal)
     assert len(processed_signals) == 1
     assert processed_signals[0].shape == (704,)  # longer than input due to smearing
-    assert np.sum(np.abs(processed_signals[0])) == pytest.approx(0.5448164192836198)
+    assert np.sum(np.abs(processed_signals[0])) == pytest.approx(
+        0.5448164192836198, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
 
     signal2 = np.random.rand(500, 2)
     processed_signals = ear.process(signal2)
     assert len(processed_signals) == 2
     assert processed_signals[0].shape == (704,)
     assert processed_signals[1].shape == (704,)
-    assert np.sum(np.abs(processed_signals[0])) == pytest.approx(0.4983800098691009)
-    assert np.sum(np.abs(processed_signals[1])) == pytest.approx(0.514370533984153)
+    assert np.sum(np.abs(processed_signals[0])) == pytest.approx(
+        0.4983800098691009, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
+    assert np.sum(np.abs(processed_signals[1])) == pytest.approx(
+        0.514370533984153, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
 
     signal1 = np.random.rand(500, 1)
     processed_signals = ear.process(signal1)
     assert len(processed_signals) == 1
     assert processed_signals[0].shape == (704,)
-    assert np.sum(np.abs(processed_signals[0])) == pytest.approx(0.5840767608116147)
+    assert np.sum(np.abs(processed_signals[0])) == pytest.approx(
+        0.5840767608116147, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
 
     signal = np.random.rand(500)
     processed_signals = ear.process(signal, add_calibration=True)
     assert len(processed_signals) == 1
     assert processed_signals[0].shape == (119808,)
-    assert np.sum(np.abs(processed_signals[0])) == pytest.approx(48480.61370282146)
+    assert np.sum(np.abs(processed_signals[0])) == pytest.approx(
+        48480.61370282146, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
 
 
 def test_ear_process_error():
diff --git a/tests/evaluator/msbg/test_msbg_utils.py b/tests/evaluator/msbg/test_msbg_utils.py
index e742317b3..c292a0ad2 100644
--- a/tests/evaluator/msbg/test_msbg_utils.py
+++ b/tests/evaluator/msbg/test_msbg_utils.py
@@ -9,9 +9,8 @@
     measure_rms,
     pad,
     read_gtf_file,
-    read_signal,
-    write_signal,
 )
+from clarity.utils.file_io import read_signal, write_signal
 
 # import scipy
 
@@ -64,18 +63,22 @@ def test_firwin2():
     #    window=None,  # ("kaiser", 4)
     # )
     assert params.shape == (128,)
-    assert np.sum(np.abs(params)) == pytest.approx(2.5662415502127844)
+    assert np.sum(np.abs(params)) == pytest.approx(
+        2.5662415502127844, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
 
     # TODO: check why the test below fails
     # I thought this was meant to give something like the scipy version, but it doesn't
-    # assert np.allclose(params, params_scipy)
+    # assert params == pytest.approx(params_scipy)
 
 
 def test_gen_tone():
     """Test gen_tone"""
     signal = gen_tone(500, 0.1, 44100.0, 20.0)
     assert signal.shape == (4410,)
-    assert np.sum(np.abs(signal)) == pytest.approx(39703.40087188665)
+    assert np.sum(np.abs(signal)) == pytest.approx(
+        39703.40087188665, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
 
 
 def test_gen_eh2008_speech_noise():
@@ -83,7 +86,9 @@ def test_gen_eh2008_speech_noise():
     np.random.seed(0)
     signal = gen_eh2008_speech_noise(0.1, 44100.0, 0.0)
     assert signal.shape == (4410,)
-    assert np.sum(np.abs(signal)) == pytest.approx(3544.690935132768)
+    assert np.sum(np.abs(signal)) == pytest.approx(
+        3544.690935132768, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
 
 
 def test_measure_rms():
@@ -92,11 +97,19 @@ def test_measure_rms():
     signal = gen_tone(500, 0.05, 44100.0, 20.0)
     noise = gen_eh2008_speech_noise(0.05, 44100.0, 0.0)
     rms, idx, rel_dB_thresh, active = measure_rms(signal + noise, 44100, 0.0, 10.0)
-    assert rms == pytest.approx(10.723997044548266)
+    assert rms == pytest.approx(
+        10.723997044548266, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
     assert idx.shape == (441,)
-    assert np.sum(idx) == pytest.approx(97020)
-    assert rel_dB_thresh == pytest.approx(-0.0008828901826447577)
-    assert active == pytest.approx(20.0)
+    assert np.sum(idx) == pytest.approx(
+        97020, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
+    assert rel_dB_thresh == pytest.approx(
+        -0.0008828901826447577, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
+    assert active == pytest.approx(
+        20.0, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
 
 
 @pytest.mark.parametrize(
@@ -110,7 +123,9 @@ def test_pad(initial, desired_length):
     """Test pad function"""
     result = pad(initial, desired_length)
     assert result.shape == (desired_length,)
-    assert np.sum(np.abs(result)) == pytest.approx(np.sum(np.abs(initial)))
+    assert np.sum(np.abs(result)) == pytest.approx(
+        np.sum(np.abs(initial)), rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
 
 
 def test_pad_error():
diff --git a/tests/evaluator/msbg/test_smearing.py b/tests/evaluator/msbg/test_smearing.py
index 9d87de5d2..0ae93ae0b 100644
--- a/tests/evaluator/msbg/test_smearing.py
+++ b/tests/evaluator/msbg/test_smearing.py
@@ -17,14 +17,18 @@ def test_audfilt():
         rl=R_LOWER, ru=R_UPPER, sample_rate=SAMPLE_RATE, asize=n_taps
     )
     assert filter_params.shape == (n_taps, n_taps)
-    assert np.sum(np.abs(filter_params)) == pytest.approx(19.879915844855944)
+    assert np.sum(np.abs(filter_params)) == pytest.approx(
+        19.879915844855944, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
 
 
 def test_make_smear_mat3_valid_input():
     """Tests that make_smear_mat3 returns matrix with the correct dimensions"""
     f_smear = make_smear_mat3(rl=R_LOWER, ru=R_UPPER, sample_rate=SAMPLE_RATE)
     assert f_smear.shape == (256, 256)
-    assert np.sum(np.abs(f_smear)) == pytest.approx(2273.976168294156)
+    assert np.sum(np.abs(f_smear)) == pytest.approx(
+        2273.976168294156, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
 
 
 def test_smear3():
@@ -34,7 +38,9 @@ def test_smear3():
     smear_mat = make_smear_mat3(rl=R_LOWER, ru=R_UPPER, sample_rate=SAMPLE_RATE)
     output_signal = smear3(smear_mat, input_signal)
     assert output_signal.shape == (10240,)
-    assert np.sum(np.abs(output_signal)) == pytest.approx(5066.986397433977)
+    assert np.sum(np.abs(output_signal)) == pytest.approx(
+        5066.986397433977, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
 
 
 def test_change_filter():
diff --git a/tests/predictor/test_torch_msbg.py b/tests/predictor/test_torch_msbg.py
index 0e2a9c833..d7b50c91e 100644
--- a/tests/predictor/test_torch_msbg.py
+++ b/tests/predictor/test_torch_msbg.py
@@ -3,12 +3,12 @@
 import pytest
 import torch
 
-from clarity.evaluator.msbg.audiogram import (
+from clarity.predictor.torch_msbg import MSBGHearingModel, torchloudnorm
+from clarity.utils.audiogram import (
     AUDIOGRAM_MILD,
     AUDIOGRAM_MODERATE,
     AUDIOGRAM_MODERATE_SEVERE,
 )
-from clarity.predictor.torch_msbg import MSBGHearingModel, torchloudnorm
 
 
 @pytest.fixture
@@ -25,7 +25,7 @@ def msbg_model():
     """MSBG model fixture"""
     model = MSBGHearingModel(
         audiogram=AUDIOGRAM_MODERATE.levels.tolist(),
-        audiometric=AUDIOGRAM_MODERATE.cfs.tolist(),
+        audiometric=AUDIOGRAM_MODERATE.frequencies.tolist(),
     )
     return model
 
@@ -35,7 +35,7 @@ def msbg_model_quick():
     """MSBG model fixture with smaller kernel for quick testing"""
     model = MSBGHearingModel(
         audiogram=AUDIOGRAM_MODERATE.levels.tolist(),
-        audiometric=AUDIOGRAM_MODERATE.cfs.tolist(),
+        audiometric=AUDIOGRAM_MODERATE.frequencies.tolist(),
         kernel_size=129,
     )
     return model
@@ -48,22 +48,22 @@ def test_msbg_hearing_model_init(use_torch):
     """Test the MSBGHearingModel class init function"""
     model = MSBGHearingModel(
         audiogram=AUDIOGRAM_MILD.levels.tolist(),
-        audiometric=AUDIOGRAM_MILD.cfs.tolist(),
+        audiometric=AUDIOGRAM_MILD.frequencies.tolist(),
     )
     assert model.win_len == 441
     model = MSBGHearingModel(
         audiogram=AUDIOGRAM_MODERATE.levels.tolist(),
-        audiometric=AUDIOGRAM_MODERATE.cfs.tolist(),
+        audiometric=AUDIOGRAM_MODERATE.frequencies.tolist(),
     )
     assert model.win_len == 441
     model = MSBGHearingModel(
         audiogram=AUDIOGRAM_MODERATE_SEVERE.levels.tolist(),
-        audiometric=AUDIOGRAM_MODERATE_SEVERE.cfs.tolist(),
+        audiometric=AUDIOGRAM_MODERATE_SEVERE.frequencies.tolist(),
     )
     assert model.win_len == 441
     model = MSBGHearingModel(
         audiogram=AUDIOGRAM_MODERATE.levels.tolist(),
-        audiometric=AUDIOGRAM_MODERATE.cfs.tolist(),
+        audiometric=AUDIOGRAM_MODERATE.frequencies.tolist(),
     )
     assert model.win_len == 441
 
@@ -75,8 +75,12 @@ def test_msbg_hearing_model_measure_rms(use_torch, msbg_model):
     y_torch = msbg_model.measure_rms(x)
     y = y_torch.cpu().detach().numpy()
     assert y.shape == (2, 1)
-    assert y[0, 0] == pytest.approx(1.0040439)
-    assert y[1, 0] == pytest.approx(0.99920964)
+    assert y[0, 0] == pytest.approx(
+        1.0040439, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
+    assert y[1, 0] == pytest.approx(
+        0.99920964, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
 
 
 def test_msbg_hearing_model_calibrate_spl(use_torch, msbg_model):
@@ -86,14 +90,16 @@ def test_msbg_hearing_model_calibrate_spl(use_torch, msbg_model):
     y_torch = msbg_model.calibrate_spl(x)
     y = y_torch.cpu().detach().numpy()
     assert y.shape == (2, 20000)
-    assert np.sum(np.abs(y)) == pytest.approx(32026.688)
+    assert np.sum(np.abs(y)) == pytest.approx(
+        32026.688, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
 
 
 def test_msbg_hearing_model_calibrate_spl_null(use_torch):
     """Test the calibrate_spl function does nothing is calibration is disabled"""
     this_msbg_model = MSBGHearingModel(
         audiogram=AUDIOGRAM_MODERATE.levels.tolist(),
-        audiometric=AUDIOGRAM_MODERATE.cfs.tolist(),
+        audiometric=AUDIOGRAM_MODERATE.frequencies.tolist(),
         spl_cali=False,  # <--- Disable calibration
     )
     x = torch.randn(2, 20000)
@@ -102,7 +108,9 @@ def test_msbg_hearing_model_calibrate_spl_null(use_torch):
     y_torch = this_msbg_model.calibrate_spl(x)
     y = y_torch.cpu().detach().numpy()
     assert y.shape == (2, 20000)
-    assert np.sum(np.abs(y)) == pytest.approx(initial_sum)  # <--- No change
+    assert np.sum(np.abs(y)) == pytest.approx(
+        initial_sum, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )  # <--- No change
 
 
 def test_msbg_hearing_model_src_to_cochlea_filt(use_torch, msbg_model):
@@ -112,7 +120,9 @@ def test_msbg_hearing_model_src_to_cochlea_filt(use_torch, msbg_model):
     y_torch = msbg_model.src_to_cochlea_filt(x, msbg_model.cochlea_filter_forward)
     y = y_torch.cpu().detach().numpy()
     assert y.shape == (1, 20000)
-    assert np.sum(np.abs(y)) == pytest.approx(14804.21875)
+    assert np.sum(np.abs(y)) == pytest.approx(
+        14804.21875, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
 
 
 def test_msbg_hearing_model_smear(use_torch, msbg_model):
@@ -122,7 +132,9 @@ def test_msbg_hearing_model_smear(use_torch, msbg_model):
     y_torch = msbg_model.smear(x)
     y = y_torch.cpu().detach().numpy()
     assert y.shape == (2, 1, 20000)
-    assert np.sum(np.abs(y)) == pytest.approx(29985.154)
+    assert np.sum(np.abs(y)) == pytest.approx(
+        29985.154, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
 
 
 def test_msbg_hearing_model_recruitment(use_torch, msbg_model_quick):
@@ -132,7 +144,9 @@ def test_msbg_hearing_model_recruitment(use_torch, msbg_model_quick):
     y_torch = msbg_model_quick.recruitment(x)
     y = y_torch.cpu().detach().numpy()
     assert y.shape == (2, 1, 10000)
-    assert np.sum(np.abs(y)) == pytest.approx(13046.6328125)
+    assert np.sum(np.abs(y)) == pytest.approx(
+        13046.6328125, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
 
 
 def test_msbg_hearing_model_recruitment_fir(use_torch, msbg_model_quick):
@@ -142,7 +156,9 @@ def test_msbg_hearing_model_recruitment_fir(use_torch, msbg_model_quick):
     y_torch = msbg_model_quick.recruitment_fir(x)
     y = y_torch.cpu().detach().numpy()
     assert y.shape == (1, 1, 10000)
-    assert np.sum(np.abs(y)) == pytest.approx(5229.41162109375)
+    assert np.sum(np.abs(y)) == pytest.approx(
+        5229.41162109375, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
 
 
 def test_msbg_hearing_model_forward(use_torch, msbg_model_quick):
@@ -152,7 +168,8 @@ def test_msbg_hearing_model_forward(use_torch, msbg_model_quick):
     y_torch = msbg_model_quick.forward(x)
     y = y_torch.cpu().detach().numpy()
     assert y.shape == (2, 10000)
-    assert np.sum(np.abs(y)) == pytest.approx(8778.4501953125)
+    # Tolerances below slightly relaxed from the 1e-7 default
+    assert np.sum(np.abs(y)) == pytest.approx(8778.4501953125, rel=1e-6, abs=1e-6)
 
 
 # Tests for torchloudnorm class
@@ -169,7 +186,9 @@ def test_torchloudnorm_apply_filter(use_torch):
     y = y_torch.cpu().detach().numpy()
 
     assert y.shape == (2, 1, 40000)
-    assert np.sum(np.abs(y)) == pytest.approx(98578.4921875)
+    assert np.sum(np.abs(y)) == pytest.approx(
+        98578.4921875, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
 
 
 def test_torchloudnorm_integrated_loudness(use_torch):
@@ -183,7 +202,9 @@ def test_torchloudnorm_integrated_loudness(use_torch):
     y = y_torch.cpu().detach().numpy()
 
     assert y.shape == (2, 1)
-    assert np.sum(np.abs(y)) == pytest.approx(6.166536331176758)
+    assert np.sum(np.abs(y)) == pytest.approx(
+        6.166536331176758, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
 
 
 def test_torchloudnorm_normalise_loudness(use_torch):
@@ -197,7 +218,9 @@ def test_torchloudnorm_normalise_loudness(use_torch):
     y = y_torch.cpu().detach().numpy()
 
     assert y.shape == (2, 20000)
-    assert np.sum(np.abs(y)) == pytest.approx(33388.645)
+    assert np.sum(np.abs(y)) == pytest.approx(
+        33388.645, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
     assert True
 
 
@@ -210,4 +233,6 @@ def test_torchloudnorm_forward(use_torch):
     y_torch = loud_norm.forward(x)  # i.e. sane as `y_torch = loud_norm(x)`
     y = y_torch.cpu().detach().numpy()
     assert y.shape == (2, 20000)
-    assert np.sum(np.abs(y)) == pytest.approx(355.77362060546875)
+    assert np.sum(np.abs(y)) == pytest.approx(
+        355.77362060546875, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
diff --git a/tests/predictor/test_torch_stoi.py b/tests/predictor/test_torch_stoi.py
index faf4ce2d2..78a2bf347 100644
--- a/tests/predictor/test_torch_stoi.py
+++ b/tests/predictor/test_torch_stoi.py
@@ -94,7 +94,9 @@ def test_negstoi_loss_stft(use_torch):
     signal = torch.rand(1, 16000)
     stft = NegSTOILoss.stft(x=signal, win=win, fft_size=fft_size, overlap=4)
     assert stft.shape == torch.Size([1, 9, 3998, 2])
-    assert stft.cpu().detach().numpy().sum() == pytest.approx(8025.33251953125)
+    assert stft.cpu().detach().numpy().sum() == pytest.approx(
+        8025.33251953125, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
 
 
 def test_negstoi_loss_rowcolnorn(use_torch):
@@ -105,7 +107,9 @@ def test_negstoi_loss_rowcolnorn(use_torch):
     y = NegSTOILoss.rowcol_norm(x)
     assert y.shape == torch.Size(x_size)
     y_array = y.cpu().detach().numpy()
-    assert np.sum(np.abs(y_array)) == pytest.approx(2589.76416015625)
+    assert np.sum(np.abs(y_array)) == pytest.approx(
+        2589.76416015625, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
 
 
 def test_meanvar_norm(use_torch):
diff --git a/tests/recipes/cad1/task1/baseline/test_enhance_task1.py b/tests/recipes/cad1/task1/baseline/test_enhance_task1.py
index 88bdd389f..b8cf8e8db 100644
--- a/tests/recipes/cad1/task1/baseline/test_enhance_task1.py
+++ b/tests/recipes/cad1/task1/baseline/test_enhance_task1.py
@@ -9,6 +9,7 @@
 
 from clarity.enhancer.compressor import Compressor
 from clarity.enhancer.nalr import NALR
+from clarity.utils.audiogram import Audiogram, Listener
 
 # pylint: disable=import-error, no-name-in-module
 from recipes.cad1.task1.baseline.enhance import (
@@ -77,14 +78,16 @@ def test_decompose_signal(separation_model):
         }
     )
     # Call the decompose_signal function and check that the output has the expected keys
+    cfs = np.array([250, 500, 1000, 2000, 4000, 6000, 8000, 9000, 10000])
+    audiogram = Audiogram(levels=np.ones(9), frequencies=cfs)
+    listener = Listener(audiogram, audiogram)
     output = decompose_signal(
         config,
         model,
         signal,
         sample_rate,
         device,
-        left_audiogram=np.ones(9),
-        right_audiogram=np.ones(9),
+        listener,
     )
     expected_results = np.load(
         RESOURCES / f"test_enhance.test_decompose_signal_{separation_model}.npy",
@@ -100,8 +103,10 @@ def test_apply_baseline_ha():
     np.random.seed(987654321)
     # Create mock inputs
     signal = np.random.normal(size=44100)
-    listener_audiogram = np.ones(9)
-    cfs = np.array([250, 500, 1000, 2000, 4000, 6000, 8000, 9000, 10000])
+    listener_audiogram = Audiogram(
+        levels=np.ones(9),
+        frequencies=np.array([250, 500, 1000, 2000, 4000, 6000, 8000, 9000, 10000]),
+    )
 
     # Create mock objects for enhancer and compressor
     enhancer = NALR(nfir=220, sample_rate=44100)
@@ -110,7 +115,7 @@ def test_apply_baseline_ha():
     )
 
     # Call the apply_nalr function and check that the output is as expected
-    output = apply_baseline_ha(enhancer, compressor, signal, listener_audiogram, cfs)
+    output = apply_baseline_ha(enhancer, compressor, signal, listener_audiogram)
 
     expected_results = np.load(
         RESOURCES / "test_enhance.test_apply_baseline_ha.npy",
@@ -127,10 +132,12 @@ def test_process_stems_for_listener():
         "l_source1": np.random.normal(size=16000),
         "r_source1": np.random.normal(size=16000),
     }
-    audiogram_left = np.ones(9)
-    audiogram_right = np.ones(9)
-    cfs = np.array([250, 500, 1000, 2000, 4000, 6000, 8000, 9000, 10000])
 
+    audiogram = Audiogram(
+        levels=np.ones(9),
+        frequencies=np.array([250, 500, 1000, 2000, 4000, 6000, 8000, 9000, 10000]),
+    )
+    listener = Listener(audiogram_left=audiogram, audiogram_right=audiogram)
     # Create mock objects for enhancer and compressor
     enhancer = NALR(nfir=220, sample_rate=16000)
     compressor = Compressor(
@@ -139,7 +146,7 @@ def test_process_stems_for_listener():
 
     # Call the process_stems_for_listener function and check output is as expected
     output_stems = process_stems_for_listener(
-        stems, enhancer, compressor, audiogram_left, audiogram_right, cfs
+        stems, enhancer, compressor, listener=listener
     )
     expected_results = np.load(
         RESOURCES / "test_enhance.test_process_stems_for_listener.npy",
diff --git a/tests/recipes/cad1/task1/baseline/test_evaluate.py b/tests/recipes/cad1/task1/baseline/test_evaluate.py
index 3e1e07d6a..5b8a06120 100644
--- a/tests/recipes/cad1/task1/baseline/test_evaluate.py
+++ b/tests/recipes/cad1/task1/baseline/test_evaluate.py
@@ -6,6 +6,7 @@
 from omegaconf import DictConfig
 from scipy.io import wavfile
 
+from clarity.utils.audiogram import Audiogram, Listener
 from recipes.cad1.task1.baseline.evaluate import (
     ResultsFile,
     _evaluate_song_listener,
@@ -20,7 +21,7 @@ def test_results_file(tmp_path):
     result_file = ResultsFile(results_file.as_posix())
     result_file.write_header()
     result_file.add_result(
-        listener="My listener",
+        listener_id="My listener",
         song="My favorite song",
         score=0.9,
         instruments_scores={
@@ -80,7 +81,7 @@ def test_make_song_listener_list():
 
 
 @pytest.mark.parametrize(
-    "song,listener,config,split_dir,listener_audiograms,expected_results",
+    "song,listener_id,config,split_dir,listener_audiograms,expected_results",
     [
         (
             "punk_is_not_dead",
@@ -100,24 +101,37 @@ def test_make_song_listener_list():
                 }
             },
             {
-                "left_drums": 0.140304064,
-                "right_drums": 0.127870857,
-                "left_bass": 0.149049349,
-                "right_bass": 0.132706378,
-                "left_other": 0.140922507,
-                "right_other": 0.162374351,
-                "left_vocals": 0.119106033,
-                "right_vocals": 0.123879321,
+                "left_drums": 0.205517835,
+                "right_drums": 0.270553157,
+                "left_bass": 0.207187220,
+                "right_bass": 0.205454381,
+                "left_other": 0.237097711,
+                "right_other": 0.227505708,
+                "left_vocals": 0.227105999,
+                "right_vocals": 0.272616615,
             },
         )
     ],
 )
 def test_evaluate_song_listener(
-    song, listener, config, split_dir, listener_audiograms, expected_results, tmp_path
+    song,
+    listener_id,
+    config,
+    split_dir,
+    listener_audiograms,
+    expected_results,
+    tmp_path,
 ):
     """Test the function _evaluate_song_listener returns correct results given input"""
     np.random.seed(2023)
-
+    listener_data = listener_audiograms[listener_id]
+    audiogram_left = Audiogram(
+        listener_data["audiogram_levels_l"], listener_data["audiogram_cfs"]
+    )
+    audiogram_right = Audiogram(
+        listener_data["audiogram_levels_r"], listener_data["audiogram_cfs"]
+    )
+    listener = Listener(audiogram_left, audiogram_right, id=listener_id)
     # Generate reference and enhanced wav files
     enhanced_folder = tmp_path / "enhanced"
     enhanced_folder.mkdir()
@@ -132,9 +146,9 @@ def test_evaluate_song_listener(
         # enhanced signals are mono
         enh_file = (
             enhanced_folder
-            / f"{listener}"
+            / f"{listener.id}"
             / f"{song}"
-            / f"{listener}_{song}_{lr_instrument}.wav"
+            / f"{listener.id}_{song}_{lr_instrument}.wav"
         )
         enh_file.parent.mkdir(exist_ok=True, parents=True)
 
@@ -161,7 +175,6 @@ def test_evaluate_song_listener(
         listener,
         config,
         split_dir,
-        listener_audiograms[listener],
         enhanced_folder,
     )
 
@@ -169,7 +182,7 @@ def test_evaluate_song_listener(
     # Combined score
     assert isinstance(combined_score, float)
     assert combined_score == pytest.approx(
-        0.137026607, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+        0.231629828, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
     )
 
     # Per instrument score
diff --git a/tests/recipes/cad1/task1/test_enhance_task1.py b/tests/recipes/cad1/task1/test_enhance_task1.py
deleted file mode 100644
index 88bdd389f..000000000
--- a/tests/recipes/cad1/task1/test_enhance_task1.py
+++ /dev/null
@@ -1,229 +0,0 @@
-"""Tests for the enhance module"""
-from pathlib import Path
-
-import numpy as np
-import pytest
-import torch
-from omegaconf import DictConfig
-from torchaudio.pipelines import HDEMUCS_HIGH_MUSDB
-
-from clarity.enhancer.compressor import Compressor
-from clarity.enhancer.nalr import NALR
-
-# pylint: disable=import-error, no-name-in-module
-from recipes.cad1.task1.baseline.enhance import (
-    apply_baseline_ha,
-    clip_signal,
-    decompose_signal,
-    get_device,
-    map_to_dict,
-    process_stems_for_listener,
-    separate_sources,
-    to_16bit,
-)
-
-BASE_DIR = Path.cwd()
-RESOURCES = BASE_DIR / "tests" / "resources" / "recipes" / "cad1" / "task1"
-
-
-def test_map_to_dict():
-    """Test that the map_to_dict returns the expected mapping"""
-    sources = np.array([[1, 2], [3, 4], [5, 6]])
-    sources_list = ["a", "b", "c"]
-    output = map_to_dict(sources, sources_list)
-    expected_output = {
-        "left_a": 1,
-        "right_a": 2,
-        "left_b": 3,
-        "right_b": 4,
-        "left_c": 5,
-        "right_c": 6,
-    }
-    assert output == expected_output
-
-
-@pytest.mark.parametrize(
-    "separation_model",
-    [
-        pytest.param("demucs"),
-        pytest.param("openunmix", marks=pytest.mark.slow),
-    ],
-)
-def test_decompose_signal(separation_model):
-    """Takes a signal and decomposes it into VDBO sources using the HDEMUCS model"""
-    np.random.seed(123456789)
-    # Load Separation Model
-    if separation_model == "demucs":
-        model = HDEMUCS_HIGH_MUSDB.get_model().double()
-    elif separation_model == "openunmix":
-        model = torch.hub.load("sigsep/open-unmix-pytorch", "umxhq").double()
-
-    device = torch.device("cpu")
-    model.to(device)
-
-    # Create a mock signal to decompose
-    sample_rate = 44100
-    duration = 0.5
-    signal = np.random.uniform(size=(1, 2, int(sample_rate * duration)))
-
-    # config
-    config = DictConfig(
-        {
-            "sample_rate": sample_rate,
-            "separator": {
-                "model": "demucs",
-                "sources": ["drums", "bass", "other", "vocals"],
-            },
-        }
-    )
-    # Call the decompose_signal function and check that the output has the expected keys
-    output = decompose_signal(
-        config,
-        model,
-        signal,
-        sample_rate,
-        device,
-        left_audiogram=np.ones(9),
-        right_audiogram=np.ones(9),
-    )
-    expected_results = np.load(
-        RESOURCES / f"test_enhance.test_decompose_signal_{separation_model}.npy",
-        allow_pickle=True,
-    )[()]
-
-    for key, item in output.items():
-        np.testing.assert_array_almost_equal(item, expected_results[key])
-
-
-def test_apply_baseline_ha():
-    """Test the behaviour of the CAD1 - Task1 - baseline hearing aid"""
-    np.random.seed(987654321)
-    # Create mock inputs
-    signal = np.random.normal(size=44100)
-    listener_audiogram = np.ones(9)
-    cfs = np.array([250, 500, 1000, 2000, 4000, 6000, 8000, 9000, 10000])
-
-    # Create mock objects for enhancer and compressor
-    enhancer = NALR(nfir=220, sample_rate=44100)
-    compressor = Compressor(
-        threshold=0.35, attenuation=0.1, attack=50, release=1000, rms_buffer_size=0.064
-    )
-
-    # Call the apply_nalr function and check that the output is as expected
-    output = apply_baseline_ha(enhancer, compressor, signal, listener_audiogram, cfs)
-
-    expected_results = np.load(
-        RESOURCES / "test_enhance.test_apply_baseline_ha.npy",
-        allow_pickle=True,
-    )
-    np.testing.assert_array_almost_equal(output, expected_results)
-
-
-def test_process_stems_for_listener():
-    """Takes 2 stems and applies the baseline processing using a listeners audiograms"""
-    np.random.seed(12357)
-    # Create mock inputs
-    stems = {
-        "l_source1": np.random.normal(size=16000),
-        "r_source1": np.random.normal(size=16000),
-    }
-    audiogram_left = np.ones(9)
-    audiogram_right = np.ones(9)
-    cfs = np.array([250, 500, 1000, 2000, 4000, 6000, 8000, 9000, 10000])
-
-    # Create mock objects for enhancer and compressor
-    enhancer = NALR(nfir=220, sample_rate=16000)
-    compressor = Compressor(
-        threshold=0.35, attenuation=0.1, attack=50, release=1000, rms_buffer_size=0.064
-    )
-
-    # Call the process_stems_for_listener function and check output is as expected
-    output_stems = process_stems_for_listener(
-        stems, enhancer, compressor, audiogram_left, audiogram_right, cfs
-    )
-    expected_results = np.load(
-        RESOURCES / "test_enhance.test_process_stems_for_listener.npy",
-        allow_pickle=True,
-    )[()]
-
-    for key, item in output_stems.items():
-        np.testing.assert_array_almost_equal(item, expected_results[key])
-
-
-def test_separate_sources():
-    """Test that the separate_sources function returns the expected output"""
-    np.random.seed(123456789)
-
-    # Create a dummy model
-    class DummyModel(torch.nn.Module):  # pylint: disable=too-few-public-methods
-        """Dummy source separation model"""
-
-        def __init__(self, sources):
-            """dummy init"""
-            super().__init__()
-            self.sources = sources
-
-        def forward(self, x):
-            """dummy forward"""
-            return torch.Tensor(
-                np.random.uniform(size=(x.shape[0], len(self.sources), *x.shape[1:]))
-            )
-
-    # Set up some dummy input data
-    batch_size = 1
-    num_channels = 1
-    length = 1
-    sample_rate = 16000
-    sources = ["vocals", "drums", "bass", "other"]
-    mix = np.random.randn(batch_size, num_channels, length * sample_rate)
-    device = torch.device("cpu")
-
-    # Create a dummy model
-    model = DummyModel(sources)
-
-    # Call separate_sources
-    output = separate_sources(model, mix, sample_rate, device=device)
-
-    expected_results = np.load(
-        RESOURCES / "test_enhance.test_separate_sources.npy",
-        allow_pickle=True,
-    )
-    # Check that the output has the correct shape
-    assert output.shape == expected_results.shape
-    np.testing.assert_array_almost_equal(output, expected_results)
-
-
-def test_get_device():
-    """Test the correct device selection given the inputs"""
-    # Test default case (no argument passed)
-    device, device_type = get_device(None)
-    assert (
-        device == torch.device("cuda")
-        if torch.cuda.is_available()
-        else torch.device("cpu")
-    )
-    assert device_type == "cuda" if torch.cuda.is_available() else "cpu"
-
-
-def test_to_16bit():
-    # Generate a random signal
-    signal = np.random.uniform(low=-1.0, high=1.0, size=50)
-    signal_16bit = to_16bit(signal)
-
-    assert np.all(np.abs(signal_16bit) <= 32768)
-
-
-def test_clip_signal():
-    # Generate a random signal
-    np.random.seed(0)
-    signal = np.random.uniform(low=-2.0, high=2.0, size=50)
-
-    # Test with soft clipping
-    clipped_signal, n_clipped = clip_signal(signal, soft_clip=True)
-    assert max(np.abs(clipped_signal)) <= 1.0
-    assert n_clipped == 0
-
-    # Test without soft clipping
-    clipped_signal, n_clipped = clip_signal(signal, soft_clip=False)
-    assert max(np.abs(clipped_signal)) <= 1.0
-    assert n_clipped == 22
diff --git a/tests/recipes/cad1/task1/test_haaqi_rms.py b/tests/recipes/cad1/task1/test_haaqi_rms.py
deleted file mode 100644
index 84dad02d5..000000000
--- a/tests/recipes/cad1/task1/test_haaqi_rms.py
+++ /dev/null
@@ -1,85 +0,0 @@
-"""Test for the haaqi-rms module"""
-import numpy as np
-import pytest
-
-from recipes.cad1.task1.baseline.haaqi_rms import (
-    align_signals,
-    compute_haaqi_rms,
-    find_silence_segments,
-)
-
-
-def test_align_signals():
-    """Test the function align_signals"""
-    np.random.seed(0)
-    sig_len = 600
-    reference_signal = 100 * np.random.random(size=sig_len)
-    processed_signal = reference_signal.copy()
-    processed_signal[50:] = processed_signal[:-50]
-    processed_signal[0:50] = 0
-
-    proc = align_signals(reference_signal, processed_signal)
-
-    assert proc.shape == (600,)
-    assert np.sum(np.abs(reference_signal)) == pytest.approx(
-        29892.167176853407, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
-    )
-    assert np.sum(np.abs(proc)) == pytest.approx(
-        27199.009291096496, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
-    )
-
-
-def test_find_silence_segments():
-    """Test the function find_silence_segments"""
-    np.random.seed(0)
-    duration = 0.5
-    sample_rate = 8000
-    sig_len = int(duration * sample_rate)
-    reference_signal = np.concatenate(
-        (
-            100 * np.random.random(size=sig_len),
-            np.zeros(sig_len),
-            100 * np.random.random(size=sig_len),
-            np.zeros(sig_len),
-        )
-    )
-
-    silence_length = 0.1
-    silence, non_silence = find_silence_segments(
-        reference_signal, sample_rate, silence_length
-    )
-    assert silence == [[4000, 7999], [12000, 15999]]
-    assert non_silence == [[0, 3999], [8000, 11999]]
-
-
-def test_compute_haaqi_rms():
-    """Test the function compute_haaqi_rms"""
-    np.random.seed(0)
-    duration = 0.5
-    sample_rate = 1000
-    sig_len = int(duration * sample_rate)
-    reference_signal = np.concatenate(
-        (
-            100 * np.random.random(size=sig_len),
-            np.zeros(sig_len),
-            100 * np.random.random(size=sig_len),
-            np.zeros(sig_len),
-        )
-    )
-    processed_signal = reference_signal.copy()
-    processed_signal[50:] = processed_signal[:-50]
-    processed_signal[0:50] = 0
-
-    processed_signal = align_signals(reference_signal, processed_signal)
-
-    score = compute_haaqi_rms(
-        processed_signal,
-        reference_signal,
-        audiogram=np.array([45, 45, 35, 45, 60, 65]),
-        audiogram_frequencies=np.array([250, 500, 1000, 2000, 4000, 6000]),
-        sample_rate=sample_rate,
-        silence_length=0.1,
-    )
-    assert score == pytest.approx(
-        0.057594470, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
-    )
diff --git a/tests/recipes/cad1/task2/baseline/test_enhance_task2.py b/tests/recipes/cad1/task2/baseline/test_enhance_task2.py
index 03b0d3e0f..27e31d830 100644
--- a/tests/recipes/cad1/task2/baseline/test_enhance_task2.py
+++ b/tests/recipes/cad1/task2/baseline/test_enhance_task2.py
@@ -6,6 +6,7 @@
 import pytest
 from omegaconf import DictConfig
 
+from clarity.utils.audiogram import Audiogram, Listener
 from recipes.cad1.task2.baseline.enhance import enhance_song
 
 BASE_DIR = Path.cwd()
@@ -25,12 +26,12 @@ def test_enhance_song():
             "enhance": {"min_level": -11, "max_level": -19, "average_level": -14},
         }
     )
-    listener = {
-        "audiogram_levels_l": [20, 30, 35, 45, 50, 60, 65, 60],
-        "audiogram_levels_r": [20, 30, 35, 45, 50, 60, 65, 60],
-        "audiogram_cfs": [250, 500, 1000, 2000, 3000, 4000, 6000, 8000],
-    }
 
+    levels = np.array([20, 30, 35, 45, 50, 60, 65, 60])
+    frequencies = np.array([250, 500, 1000, 2000, 3000, 4000, 6000, 8000])
+    audiogram = Audiogram(levels=levels, frequencies=frequencies)
+
+    listener = Listener(audiogram, audiogram)
     # Create a sample waveform
     waveform = np.random.rand(2, int(config.sample_rate * duration))
 
diff --git a/tests/recipes/cec1/baseline/test_enhance.py b/tests/recipes/cec1/baseline/test_enhance.py
index bbecad610..2e4bf20b4 100644
--- a/tests/recipes/cec1/baseline/test_enhance.py
+++ b/tests/recipes/cec1/baseline/test_enhance.py
@@ -8,7 +8,7 @@
 import pytest
 from omegaconf import DictConfig
 
-from clarity.evaluator.msbg.msbg_utils import read_signal, write_signal
+from clarity.utils.file_io import read_signal, write_signal
 
 # pylint: disable=import-error, no-name-in-module, no-member
 from recipes.cec1.baseline.enhance import enhance
@@ -70,6 +70,8 @@ def test_enhance(tmp_path: Path, hydra_cfg: DictConfig, mocker) -> None:
 
     # Check that the output signal is correct
     signal = read_signal(outfile_name)
-    assert np.sum(np.abs(signal)) == pytest.approx(1.1998291015625)
+    assert np.sum(np.abs(signal)) == pytest.approx(
+        1.1998291015625, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
 
     assert m.call_count == 1
diff --git a/tests/recipes/cec1/baseline/test_evaluate.py b/tests/recipes/cec1/baseline/test_evaluate.py
index 5f8e73a08..c0d0a740f 100644
--- a/tests/recipes/cec1/baseline/test_evaluate.py
+++ b/tests/recipes/cec1/baseline/test_evaluate.py
@@ -11,7 +11,7 @@
 from omegaconf import DictConfig
 
 import recipes
-from clarity.evaluator.msbg.msbg_utils import read_signal
+from clarity.utils.file_io import read_signal
 
 # pylint: disable=import-error, no-name-in-module, no-member
 from recipes.cec1.baseline.evaluate import run_calculate_SI, run_HL_processing
@@ -31,17 +31,22 @@ def not_tqdm(iterable):
 def truncated_read_signal(
     filename: str | Path,
     offset: int = 0,
-    nsamples: int = -1,
-    nchannels: int = 0,
+    n_samples: int = -1,
+    n_channels: int = 0,
     offset_is_samples: bool = False,
 ) -> ndarray:
     """Replacement for read signal function.
 
     Returns first 1 second of the signal
     """
-    signal = read_signal(filename, offset, nsamples, nchannels, offset_is_samples)
-    # Take 1 second sample from the middle of the signal
-    return signal[0:44100, :]
+    n_samples = 44100  # <-- take just the first 1 second of the signal
+    return read_signal(
+        filename,
+        offset=offset,
+        n_channels=n_channels,
+        n_samples=n_samples,
+        offset_is_samples=offset_is_samples,
+    )
 
 
 @pytest.fixture()
@@ -90,7 +95,9 @@ def test_run_HL_processing(tmp_path: Path, hydra_cfg: DictConfig) -> None:
     for filename, sig_sum in expected_files:
         assert (pathlib.Path(f"{tmp_path}/eval_signals/{filename}")).exists()
         x = read_signal(f"{tmp_path}/eval_signals/{filename}")
-        assert np.sum(np.abs(x)) == pytest.approx(sig_sum)
+        assert np.sum(np.abs(x)) == pytest.approx(
+            sig_sum, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+        )
 
 
 @patch("recipes.cec1.baseline.evaluate.tqdm", not_tqdm)
diff --git a/tests/recipes/cec1/data_preparation/test_prepare_cec1_data.py b/tests/recipes/cec1/data_preparation/test_prepare_cec1_data.py
index aff461a90..5032ed253 100644
--- a/tests/recipes/cec1/data_preparation/test_prepare_cec1_data.py
+++ b/tests/recipes/cec1/data_preparation/test_prepare_cec1_data.py
@@ -6,7 +6,7 @@
 import numpy as np
 import pytest
 
-from clarity.evaluator.msbg.msbg_utils import read_signal
+from clarity.utils.file_io import read_signal
 from recipes.cec1.data_preparation import prepare_cec1_data
 
 
@@ -67,4 +67,6 @@ def test_prepare_data(tmp_path):
         assert filename.exists()
         # Check that the output signal is correct
         signal = read_signal(filename)
-        assert np.sum(np.abs(signal)) == pytest.approx(expected_sum)
+        assert np.sum(np.abs(signal)) == pytest.approx(
+            expected_sum, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+        )
diff --git a/tests/recipes/cec1/e009_sheffield/test_test.py b/tests/recipes/cec1/e009_sheffield/test_test.py
index 5debeb123..4d8809974 100644
--- a/tests/recipes/cec1/e009_sheffield/test_test.py
+++ b/tests/recipes/cec1/e009_sheffield/test_test.py
@@ -9,7 +9,7 @@
 
 from clarity.enhancer.dnn.mc_conv_tasnet import ConvTasNet
 from clarity.enhancer.dsp.filter import AudiometricFIR
-from clarity.evaluator.msbg.msbg_utils import read_signal
+from clarity.utils.file_io import read_signal
 from recipes.cec1.e009_sheffield.test import run
 
 
@@ -61,4 +61,7 @@ def test_run(tmp_path):
     assert (tmp_path / expected_output_file).exists()
     signal = read_signal(tmp_path / expected_output_file)
     assert signal.shape == (259200, 2)
-    assert np.sum(np.abs(signal)) == pytest.approx(4331.347137451172)
+    # Tolerances below slightly relaxed from the 1e-7 default
+    assert np.sum(np.abs(signal)) == pytest.approx(
+        4331.347137451172, rel=1e-6, abs=1e-6
+    )
diff --git a/tests/recipes/cec2/baseline/test_enhance.py b/tests/recipes/cec2/baseline/test_enhance.py
index a70ef36d4..f1c54226f 100644
--- a/tests/recipes/cec2/baseline/test_enhance.py
+++ b/tests/recipes/cec2/baseline/test_enhance.py
@@ -9,7 +9,7 @@
 import pytest
 from omegaconf import DictConfig
 
-from clarity.evaluator.msbg.msbg_utils import read_signal
+from clarity.utils.file_io import read_signal
 from recipes.cec2.baseline.enhance import enhance
 
 
@@ -56,4 +56,6 @@ def test_enhance(tmp_path: Path, hydra_cfg: DictConfig) -> None:
     filename = tmp_path / "enhanced_signals" / "S06001_L0064_HA-output.wav"
     assert filename.exists()
     signal = read_signal(filename)
-    assert np.sum(np.abs(signal)) == pytest.approx(78939.73132324219)
+    assert np.sum(np.abs(signal)) == pytest.approx(
+        78939.73132324219, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
diff --git a/tests/recipes/cec2/baseline/test_evaluate.py b/tests/recipes/cec2/baseline/test_evaluate.py
index e25784091..3ea665956 100644
--- a/tests/recipes/cec2/baseline/test_evaluate.py
+++ b/tests/recipes/cec2/baseline/test_evaluate.py
@@ -99,5 +99,9 @@ def test_calulate_SI(tmp_path: Path, hydra_cfg: DictConfig):
     si_dict = read_csv_scores(Path(f"{tmp_path}/si.csv"))
     si_unproc_dict = read_csv_scores(Path(f"{tmp_path}/si_unproc.csv"))
 
-    assert si_dict["S06001_L0064"] == pytest.approx(0.745459815729705)
-    assert si_unproc_dict["S06001_L0064"] == pytest.approx(0.981990966133383)
+    assert si_dict["S06001_L0064"] == pytest.approx(
+        0.745459815729705, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
+    assert si_unproc_dict["S06001_L0064"] == pytest.approx(
+        0.981990966133383, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
diff --git a/tests/recipes/cpc1/baseline/test_run.py b/tests/recipes/cpc1/baseline/test_run.py
index 0954d46e0..6d43f53c9 100644
--- a/tests/recipes/cpc1/baseline/test_run.py
+++ b/tests/recipes/cpc1/baseline/test_run.py
@@ -12,9 +12,9 @@
 from scipy.io.wavfile import read
 
 import recipes
-from clarity.evaluator.msbg.audiogram import Audiogram
 from clarity.evaluator.msbg.msbg import Ear
-from clarity.evaluator.msbg.msbg_utils import read_signal
+from clarity.utils.audiogram import Audiogram, Listener
+from clarity.utils.file_io import read_signal
 from recipes.cpc1.baseline.run import listen, run, run_calculate_SI, run_HL_processing
 
 
@@ -59,12 +59,13 @@ def test_listen(hydra_cfg):
     levels_1 = np.array([5, 5, 5, 5, 5, 5])
     levels_2 = np.array([10, 25, 80, 80, 80, 85])
     signal = np.random.rand(2000, 2)
-    audiogram_mild = Audiogram(cfs=cfs, levels=levels_1)
-    audiogram_severe = Audiogram(cfs=cfs, levels=levels_2)
+    audiogram_mild = Audiogram(frequencies=cfs, levels=levels_1)
+    audiogram_severe = Audiogram(frequencies=cfs, levels=levels_2)
+
     # Test asymmetric hearing loss in both orientations
-    processed = listen(ear, signal, audiogram_mild, audiogram_severe)
+    processed = listen(ear, signal, Listener(audiogram_mild, audiogram_severe))
     assert processed.shape == (2240, 2)
-    processed = listen(ear, signal, audiogram_severe, audiogram_mild)
+    processed = listen(ear, signal, Listener(audiogram_severe, audiogram_mild))
     assert processed.shape == (2240, 2)
 
 
@@ -91,7 +92,9 @@ def test_run_HL_processing(hydra_cfg, tmp_path):
     ]
     for signal, expected_value in expected_signals:
         _fs, signal = read(tmp_path / "exps/train/eval_signals" / signal)
-        assert np.sum(np.abs(signal)) == pytest.approx(expected_value)
+        assert np.sum(np.abs(signal)) == pytest.approx(
+            expected_value, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+        )
 
 
 @patch("recipes.cpc1.baseline.run.tqdm", not_tqdm)
diff --git a/tests/recipes/icassp_2023/baseline/test_enhance.py b/tests/recipes/icassp_2023/baseline/test_enhance.py
index aebdc97c3..fd1304be7 100644
--- a/tests/recipes/icassp_2023/baseline/test_enhance.py
+++ b/tests/recipes/icassp_2023/baseline/test_enhance.py
@@ -9,7 +9,7 @@
 import pytest
 from omegaconf import DictConfig
 
-from clarity.evaluator.msbg.msbg_utils import read_signal
+from clarity.utils.file_io import read_signal
 from recipes.icassp_2023.baseline.enhance import enhance
 
 
@@ -55,7 +55,9 @@ def test_enhance(hydra_cfg: DictConfig) -> None:
     filename = Path("enhanced_signals/S06001_L0064_enhanced.wav")
     assert filename.exists()
     signal = read_signal(filename)
-    assert np.sum(np.abs(signal)) == pytest.approx(125253.92190551758)
+    assert np.sum(np.abs(signal)) == pytest.approx(
+        125253.92190551758, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
 
     # Note, enhance.py writes results to where this test is run from,
     # so we need to clean up.
diff --git a/tests/recipes/icassp_2023/baseline/test_evaluate.py b/tests/recipes/icassp_2023/baseline/test_evaluate.py
index 21ac0e8df..245b6372a 100644
--- a/tests/recipes/icassp_2023/baseline/test_evaluate.py
+++ b/tests/recipes/icassp_2023/baseline/test_evaluate.py
@@ -11,7 +11,7 @@
 from omegaconf import DictConfig
 
 import recipes
-from clarity.evaluator.msbg.msbg_utils import read_signal
+from clarity.utils.file_io import read_signal
 from recipes.icassp_2023.baseline.evaluate import run_calculate_si
 
 
@@ -96,7 +96,9 @@ def test_evaluate(hydra_cfg: DictConfig):
         # Check that the output signal is correct
         signal = read_signal(filename)
         Path(filename).unlink()
-        assert np.sum(np.abs(signal)) == pytest.approx(expected_sum)
+        assert np.sum(np.abs(signal)) == pytest.approx(
+            expected_sum, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+        )
 
     # Clean up
     Path("scores.csv").unlink()
diff --git a/tests/regression/test_enhancers.py b/tests/regression/test_enhancers.py
index de209fe2e..1f0e0207e 100644
--- a/tests/regression/test_enhancers.py
+++ b/tests/regression/test_enhancers.py
@@ -5,9 +5,10 @@
 import torch
 
 from clarity.enhancer.dsp import filter  # pylint: disable=redefined-builtin
-from clarity.enhancer.gha.audiogram import Audiogram
 from clarity.enhancer.gha.gha_interface import GHAHearingAid as gha
 from clarity.enhancer.gha.gha_utils import format_gaintable, get_gaintable
+from clarity.utils.audiogram import Audiogram
+from clarity.utils.file_io import read_signal
 
 gha_params = {  # hyperparameters for GHA Hearing Aid, BE CAREFUL if making changes
     "sample_rate": 44100,
@@ -34,19 +35,23 @@ def test_dsp_filter(regtest):
 
 
 def test_gha_audiogram(regtest):
-    cfs = np.array([250, 500, 1000, 2000, 4000, 8000])
+    frequencies = np.array([250, 500, 1000, 2000, 4000, 8000])
     for i in [1, 10, 30, 40]:
         np.random.seed(0)
         levels_l = np.round(np.log10(np.random.rand(6) / 20) * (-i), 0)
         levels_r = np.round(np.log10(np.random.rand(6) / 20) * (-i), 0)
-        ag = Audiogram(levels_l, levels_r, cfs)
+        ag_left = Audiogram(levels=levels_l, frequencies=frequencies)
+        ag_right = Audiogram(levels=levels_r, frequencies=frequencies)
         regtest.write(
             "Audiogram original: \n"
-            f"{ag.cfs}\n{ag.levels_l}\n{ag.levels_r}\n{ag.severity}\n"
+            f"{ag_left.frequencies}\n{ag_left.levels}\n{ag_right.levels}\n"
+            f"['{ag_left.severity}', '{ag_right.severity}']\n"
         )
-        ag = ag.select_subset_of_cfs(np.array([500, 1000, 2000]))
+        ag_left = ag_left.resample(np.array([500, 1000, 2000]))
+        ag_right = ag_right.resample(np.array([500, 1000, 2000]))
         regtest.write(
-            f"Audiogram new: \n{ag.cfs}\n{ag.levels_l}\n{ag.levels_r}\n{ag.severity}\n"
+            f"Audiogram new: \n{ag_left.frequencies}\n{ag_left.levels}\n"
+            f"{ag_right.levels}\n['{ag_left.severity}', '{ag_right.severity}']\n"
         )
 
 
@@ -61,7 +66,7 @@ def test_GHA_inputs(regtest):
     )
     enhancer.create_HA_inputs(infile_names, merged_filename)
 
-    signal = enhancer.read_signal(merged_filename)
+    signal = read_signal(merged_filename)
     np.set_printoptions(threshold=100)
 
     # outputting a segment not at start to avoid it being all zeros
@@ -74,15 +79,17 @@ def test_GHA_config(regtest):
     with open("tests/test_data/metadata/listeners.json", encoding="utf-8") as fp:
         listeners = json.load(fp)
     listener = listeners["L0001"]
-    cfs = np.array(listener["audiogram_cfs"], dtype="int")
-    audiogram_left = np.array(listener["audiogram_levels_l"])
-    audiogram_right = np.array(listener["audiogram_levels_r"])
-    audiogram = Audiogram(cfs=cfs, levels_l=audiogram_left, levels_r=audiogram_right)
+    frequencies = np.array(listener["audiogram_cfs"], dtype="int")
+    audiogram_left_levels = np.array(listener["audiogram_levels_l"])
+    audiogram_right_levels = np.array(listener["audiogram_levels_r"])
+    audiogram_left = Audiogram(frequencies=frequencies, levels=audiogram_left_levels)
+    audiogram_right = Audiogram(frequencies=frequencies, levels=audiogram_right_levels)
 
     cfg_template = f"tests/test_data/openMHA/{enhancer.cfg_file}_template.cfg"
 
     gaintable = get_gaintable(
-        audiogram,
+        audiogram_left,
+        audiogram_right,
         enhancer.noise_gate_levels,
         enhancer.noise_gate_slope,
         enhancer.cr_level,
diff --git a/tests/regression/test_full_CEC1_pipeline.py b/tests/regression/test_full_CEC1_pipeline.py
index a3e929706..fd6626659 100644
--- a/tests/regression/test_full_CEC1_pipeline.py
+++ b/tests/regression/test_full_CEC1_pipeline.py
@@ -16,9 +16,9 @@
 from clarity.enhancer.nalr import NALR
 from clarity.evaluator.mbstoi.mbstoi import mbstoi
 from clarity.evaluator.mbstoi.mbstoi_utils import find_delay_impulse
-from clarity.evaluator.msbg.audiogram import Audiogram
 from clarity.evaluator.msbg.msbg import Ear
 from clarity.evaluator.msbg.msbg_utils import MSBG_FS, pad
+from clarity.utils.audiogram import Audiogram
 
 
 def listen(ear, signal, audiogram_l, audiogram_r):
@@ -130,16 +130,16 @@ def test_full_cec1_pipeline(regtest):
 
     ear = Ear(**msbg_ear_cfg)
 
-    left_audiogram = Audiogram(cfs=audiogram_cfs, levels=audiogram_l)
-    right_audiogram = Audiogram(cfs=audiogram_cfs, levels=audiogram_r)
+    audiogram_left = Audiogram(frequencies=audiogram_cfs, levels=audiogram_l)
+    audiogram_right = Audiogram(frequencies=audiogram_cfs, levels=audiogram_r)
 
     enhancer = NALR(**nalr_cfg)
     compressor = Compressor(**compressor_cfg)
 
-    nalr_fir, _ = enhancer.build(audiogram_l, audiogram_cfs)
+    nalr_fir, _ = enhancer.build(audiogram_left)
     out_l = enhancer.apply(nalr_fir, signal[:, 0])
 
-    nalr_fir, _ = enhancer.build(audiogram_r, audiogram_cfs)
+    nalr_fir, _ = enhancer.build(audiogram_right)
     out_r = enhancer.apply(nalr_fir, signal[:, 1])
 
     out_l, _, _ = compressor.process(out_l)
@@ -156,8 +156,8 @@ def test_full_cec1_pipeline(regtest):
     ddf_signal[:, 1] = unit_impulse(len(signal), int(MSBG_FS / 2))
 
     # Pass through MSBG hearing loss model
-    reference_processed = listen(ear, reference, left_audiogram, right_audiogram)
-    signal_processed = listen(ear, enhanced_audio, left_audiogram, right_audiogram)
+    reference_processed = listen(ear, reference, audiogram_left, audiogram_right)
+    signal_processed = listen(ear, enhanced_audio, audiogram_left, audiogram_right)
 
     # Calculate channel-specific unit impulse delay due to HL model and audiograms
     delay = find_delay_impulse(ddf_signal, initial_value=int(MSBG_FS / 2))
diff --git a/tests/regression/test_full_CEC2_pipeline.py b/tests/regression/test_full_CEC2_pipeline.py
index ffe01e68b..1ccec30d3 100644
--- a/tests/regression/test_full_CEC2_pipeline.py
+++ b/tests/regression/test_full_CEC2_pipeline.py
@@ -13,6 +13,7 @@
 from clarity.enhancer.compressor import Compressor
 from clarity.enhancer.nalr import NALR
 from clarity.evaluator.haspi import haspi_v2_be
+from clarity.utils.audiogram import Audiogram, Listener
 
 # Pass some random data through code and compare with reference output
 # scene_renderer, enhancer, compressor, haspi
@@ -142,15 +143,18 @@ def test_full_cec2_pipeline(
     audiogram_r = np.array([45, 45, 60, 70, 60, 60, 80, 80])
     audiogram_cfs = np.array([250, 500, 1000, 2000, 3000, 4000, 6000, 8000])
 
+    audiogram_left = Audiogram(levels=audiogram_l, frequencies=audiogram_cfs)
+    audiogram_right = Audiogram(levels=audiogram_r, frequencies=audiogram_cfs)
+
     sample_rate = 44100
 
     enhancer = NALR(**nalr_cfg)
     compressor = Compressor(**compressor_cfg)  # type: ignore
 
-    nalr_fir, _ = enhancer.build(audiogram_l, audiogram_cfs)
+    nalr_fir, _ = enhancer.build(audiogram_left)
     out_l = enhancer.apply(nalr_fir, signal[:, 0])
 
-    nalr_fir, _ = enhancer.build(audiogram_r, audiogram_cfs)
+    nalr_fir, _ = enhancer.build(audiogram_right)
     out_r = enhancer.apply(nalr_fir, signal[:, 1])
 
     out_l, _, _ = compressor.process(out_l)
@@ -159,6 +163,7 @@ def test_full_cec2_pipeline(
     enhanced_audio = np.stack([out_l, out_r], axis=1)
 
     enhanced_audio = np.tanh(enhanced_audio)
+    listener = Listener(audiogram_left=audiogram_left, audiogram_right=audiogram_right)
 
     sii_enhanced = haspi_v2_be(
         reference_left=reference[:, 0],
@@ -166,9 +171,7 @@ def test_full_cec2_pipeline(
         processed_left=enhanced_audio[:, 0],
         processed_right=enhanced_audio[:, 1],
         sample_rate=sample_rate,
-        audiogram_left=audiogram_l,
-        audiogram_right=audiogram_r,
-        audiogram_frequencies=audiogram_cfs,
+        listener=listener,
     )
 
     regtest.write(f"Enhanced audio HASPI score is {sii_enhanced:0.7f}\n")
diff --git a/tests/regression/test_mc_conv_tasnet.py b/tests/regression/test_mc_conv_tasnet.py
index f6a602f10..e3e71cafa 100644
--- a/tests/regression/test_mc_conv_tasnet.py
+++ b/tests/regression/test_mc_conv_tasnet.py
@@ -68,13 +68,13 @@ def test_convtasnet(regtest):
             orig_freq=cfg.test_dataset["sample_rate"],
             new_freq=cfg.test_dataset["sample_rate"]
             // cfg.test_dataset.downsample_factor,
-            resampling_method="sinc_interpolation",
+            resampling_method="sinc_interp_hann",
         )
         up_sample = torchaudio.transforms.Resample(
             orig_freq=cfg.test_dataset["sample_rate"]
             // cfg.test_dataset.downsample_factor,
             new_freq=cfg.test_dataset["sample_rate"],
-            resampling_method="sinc_interpolation",
+            resampling_method="sinc_interp_hann",
         )
 
     with torch.no_grad():
diff --git a/tests/regression/test_predictors.py b/tests/regression/test_predictors.py
index 136377e83..1bb4c0f4f 100644
--- a/tests/regression/test_predictors.py
+++ b/tests/regression/test_predictors.py
@@ -1,5 +1,5 @@
 """"Regression tests for Predictors"""
-# import re
+import re
 
 import pytest
 import torch
@@ -11,10 +11,10 @@
 CPUINFO = get_cpu_info()
 
 
-# @pytest.mark.skipif(
-#    re.search("E5-2673", CPUINFO["brand_raw"]),
-#    reason="Xeon E5-2673 CPU arch gives a different value",
-# )
+@pytest.mark.skipif(
+    re.search("E5-2673", CPUINFO["brand_raw"]),
+    reason="Xeon E5-2673 CPU arch gives a different value",
+)
 def test_torch_msbg_stoi_non_xeon_e5_2673_cpu(regtest):
     torch.manual_seed(0)
     torch.set_num_threads(1)
@@ -35,11 +35,11 @@ def test_torch_msbg_stoi_non_xeon_e5_2673_cpu(regtest):
     )
 
 
-# @pytest.mark.skipif(
-#    not re.search("E5-2673", CPUINFO["brand_raw"]),
-#    reason="Test value obtained with Xeon E5-2673",
-# )
-@pytest.mark.skip(reason="no longer needed?")
+# @pytest.mark.skip(reason="no longer needed?")
+@pytest.mark.skipif(
+    not re.search("E5-2673", CPUINFO["brand_raw"]),
+    reason="Test value obtained with Xeon E5-2673",
+)
 def test_torch_msbg_stoi_xeon_e5_2673_cpu(regtest):
     torch.manual_seed(0)
     torch.set_num_threads(1)
diff --git a/tests/utils/test_audiogram.py b/tests/utils/test_audiogram.py
new file mode 100644
index 000000000..a5b5ab6e4
--- /dev/null
+++ b/tests/utils/test_audiogram.py
@@ -0,0 +1,176 @@
+"""Tests for audiogram module"""
+
+from pathlib import Path
+
+import numpy as np
+import pytest
+
+from clarity.utils.audiogram import (
+    AUDIOGRAM_MILD,
+    AUDIOGRAM_MODERATE,
+    AUDIOGRAM_MODERATE_SEVERE,
+    AUDIOGRAM_REF,
+    Audiogram,
+    Listener,
+)
+
+
+def test_audiogram():
+    """Test Audiogram class"""
+    levels = np.array([60, 70, 80])
+    cfs = np.array([250, 500, 1000])
+    audiogram = Audiogram(levels=levels, frequencies=cfs)
+    assert np.all(audiogram.levels == levels)
+    assert np.all(audiogram.frequencies == cfs)
+
+
+@pytest.mark.parametrize(
+    "cfs, levels, severity",
+    [
+        (np.array([500, 1000, 2000, 3000]), np.array([60, 70, 10, 10]), "NOTHING"),
+        (np.array([500, 1000, 2000, 3000]), np.array([60, 70, 20, 20]), "MILD"),
+        (np.array([500, 1000, 2000, 3000]), np.array([60, 70, 40, 40]), "MODERATE"),
+        (np.array([500, 1000, 2000, 3000]), np.array([60, 70, 80, 80]), "SEVERE"),
+        (np.array([500, 800, 900, 1000]), np.array([60, 70, 80, 90]), "NOTHING"),
+        (np.array([9000, 10000]), np.array([120, 120]), "NOTHING"),
+    ],
+)
+def test_audiogram_severity(cfs, levels, severity):
+    """Test Audiogram severity calculation"""
+    assert Audiogram(levels=levels, frequencies=cfs).severity == severity
+
+
+def test_standard_audiograms_are_correct_severity():
+    """Test standard audiogram"""
+    assert AUDIOGRAM_REF.severity == "NOTHING"
+    assert AUDIOGRAM_MILD.severity == "MILD"
+    assert AUDIOGRAM_MODERATE.severity == "MODERATE"
+    assert AUDIOGRAM_MODERATE_SEVERE.severity == "SEVERE"
+
+
+def test_audiogram_init():
+    """test construction of audiogram"""
+    levels = np.array([45, 45, 35, 45, 60, 65])
+    frequencies = np.array([250, 500, 1000, 2000, 4000, 6000])
+    audiogram = Audiogram(levels=levels, frequencies=frequencies)
+    assert np.all(audiogram.levels == levels)
+    assert np.all(audiogram.frequencies == frequencies)
+    assert isinstance(audiogram.levels, np.ndarray)
+
+
+def test_audiogram_init_error():
+    """test that cannot be constructed with invalid number of arguments"""
+    # Need to disable pylint so that we can test the invalid constructor calls
+    with pytest.raises(TypeError):
+        Audiogram(None)  # pylint: disable=no-value-for-parameter
+    with pytest.raises(ValueError):
+        Audiogram([1])  # pylint: disable=no-value-for-parameter
+    with pytest.raises(TypeError):
+        Audiogram(3, 4, 5, 6)  # pylint: disable=too-many-function-args
+
+
+@pytest.mark.parametrize(
+    "test_freqs, result",
+    [
+        (np.array([]), True),
+        (np.array([250, 1000]), True),
+        (np.array([250, 500, 1000]), True),
+        (np.array([250, 500, 1000, 2000]), False),
+        (np.array([2000]), False),
+    ],
+)
+def test_has_frquencies(test_freqs, result):
+    """test that frequencies are correctly identified"""
+    audiogram = Audiogram(
+        levels=np.array([45, 45, 35]), frequencies=np.array([250, 500, 1000])
+    )
+    assert audiogram.has_frequencies(test_freqs) == result
+
+
+@pytest.mark.parametrize(
+    "frequencies, levels",
+    [
+        (np.array([250.0, 250.0, 500.0]), np.array([45, 45, 35])),  # duplicate freqs
+        (np.array([250.0, 500.0]), np.array([45, 45, 35])),  # different lengths
+        (np.array([250.0, 500.0, 10.0]), np.array([45, 45, 35])),  # freqs not ordered
+    ],
+)
+def test_audiogram_invalid(frequencies, levels):
+    """test that invalid audiograms raise ValueError"""
+    with pytest.raises(ValueError):
+        Audiogram(levels=levels, frequencies=frequencies)
+
+
+@pytest.mark.parametrize(
+    "levels, frequencies, expected",
+    [
+        ([45], [250], "NOTHING"),
+        ([15], [2000], "NOTHING"),
+        ([16], [2000], "MILD"),
+        ([35], [2000], "MILD"),
+        ([36], [2000], "MODERATE"),
+        ([56], [2000], "MODERATE"),
+        ([57], [2000], "SEVERE"),
+        ([90], [8001], "NOTHING"),
+        ([16, 16], [8000, 8001], "MILD"),
+    ],
+)
+def test_severity(levels, frequencies, expected):
+    """test severity calculation"""
+    audiogram = Audiogram(levels=np.array(levels), frequencies=np.array(frequencies))
+    assert audiogram.severity == expected
+
+
+@pytest.mark.parametrize(
+    "requested_frequencies, expected_levels",
+    [
+        (np.array([250, 1000.0, 6000.0]), np.array([1.0, 3.0, 6.0])),
+        (np.array([260.0, 1000.0, 8000.0]), np.array([1.05658353, 3.0, 6.0])),
+    ],
+)
+def test_resample(requested_frequencies, expected_levels):
+    """test that a subset of cfs can be chosen"""
+    levels = np.array([1, 2, 3, 4, 5, 6])
+    frequencies = np.array([250, 500, 1000, 2000, 4000, 6000])
+    audiogram = Audiogram(levels=levels, frequencies=frequencies)
+
+    # Include cfs that don't exist in the requested subset
+    subset_audiogram = audiogram.resample(requested_frequencies)
+    assert subset_audiogram.frequencies == pytest.approx(
+        requested_frequencies, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
+    assert subset_audiogram.levels == pytest.approx(
+        expected_levels, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
+    )
+
+
+def test_listener_read_listener_dict():
+    """Test reading listener dictionary"""
+    listener_dict_path = Path("tests/test_data/metadata/listeners.json")
+    listeners_dict = Listener.load_listener_dict(listener_dict_path)
+
+    # Check the dataclass and underlying audiogram dataclass have
+    # been constructed correctly and that a sample of values
+    # match the file contents
+    assert len(listeners_dict) == 83
+    listener = listeners_dict["L0100"]
+    assert list(listener.__dict__.keys()) == ["audiogram_left", "audiogram_right", "id"]
+    assert listener.id == "L0100"
+    assert np.all(
+        listener.audiogram_left.frequencies
+        == np.array(
+            [
+                250,
+                500,
+                1000,
+                2000,
+                3000,
+                4000,
+                6000,
+                8000,
+            ]
+        )
+    )
+    assert np.all(
+        listener.audiogram_right.levels == np.array([20, 40, 55, 40, 20, 15, 5, -5])
+    )
diff --git a/tests/utils/test_file_io.py b/tests/utils/test_file_io.py
index e2aa026c7..dd0deea40 100644
--- a/tests/utils/test_file_io.py
+++ b/tests/utils/test_file_io.py
@@ -1,7 +1,8 @@
 """Test the file_io module."""
+import numpy as np
 import pytest
 
-from clarity.utils.file_io import read_jsonl, write_jsonl
+from clarity.utils.file_io import read_jsonl, read_signal, write_jsonl, write_signal
 
 
 def test_read_jsonl():
@@ -44,3 +45,193 @@ def test_jsonl_append_read_loop(records, tmp_path):
     write_jsonl(tmp_path / "test2.jsonl", records)
     write_jsonl(tmp_path / "test2.jsonl", records)
     assert read_jsonl(tmp_path / "test2.jsonl") == records + records
+
+
+@pytest.mark.parametrize(
+    "signal, floating_point, strict",
+    [
+        (np.array([1.1, -2.0, 0.0, 44.0, -54.0]), True, True),  # float32
+        (np.array([0.1, -0.2, 0.1, -0.5, 0.99]), False, True),  # int16
+        (np.array([0.1, -0.2, 0.1, -1.00, 0.99]), False, True),  # int16
+        (np.array([0.1, -0.2, 0.1, -1.00, 1.99]), False, False),  # int16
+        (np.array([0.1, -0.2, 0.1, -1.00, 0.99]), False, True),  # int16
+    ],
+)
+def test_write_read_loop(tmp_path, signal, floating_point, strict):
+    """Test write_signal and read_signal"""
+    tmp_filename = tmp_path / "test.wav"
+
+    sample_rate = 16000.0
+
+    write_signal(
+        tmp_filename,
+        signal,
+        sample_rate=int(sample_rate),  # <-- Sample rate needs to be cast to int
+        floating_point=floating_point,
+        strict=strict,
+    )
+    result = read_signal(tmp_filename, sample_rate=sample_rate)
+
+    # Some precision is lost as convert to int16 and back again
+    assert result.shape == signal.shape
+    # The test where strict is False has overflow which is not caught and hence
+    # reading back the signal has changed
+    if strict:
+        assert result == pytest.approx(signal, abs=1.0 / 16384)
+    else:
+        # Deliberate fail: shows why strict is True is needed
+        assert result != pytest.approx(signal, abs=1.0 / 16384)
+
+
+def test_write_mono_as_2D_signal(tmp_path):
+    """Test special case of writing signals for shape [N, 1]"""
+    tmp_filename_1d = tmp_path / "test_1d.wav"
+    tmp_filename_2d = tmp_path / "test_2d.wav"
+    signal_1d = np.ones((10,)) * 0.5
+    signal_2d = np.ones((10, 1)) * 0.5
+
+    write_signal(tmp_filename_1d, signal_1d, sample_rate=16000)
+    write_signal(tmp_filename_2d, signal_2d, sample_rate=16000)
+
+    result_1d = read_signal(tmp_filename_1d)
+    result_2d = read_signal(tmp_filename_2d)
+
+    # Both 1 and 2D signals should be read back as 1D
+    assert result_1d.shape == (10,)
+    assert result_2d.shape == (10,)
+
+
+def test_read_write_multichannel(tmp_path):
+    """Test write_signal and read_signal"""
+    tmp_filename = tmp_path / "test.wav"
+
+    signal = np.ones((2, 10)) * 0.5
+    sample_rate = 16000.0
+
+    write_signal(
+        tmp_filename,
+        signal,
+        sample_rate=int(sample_rate),  # <-- Sample rate needs to be cast to int
+        floating_point=False,
+        strict=True,
+    )
+    result = read_signal(tmp_filename, sample_rate=sample_rate)
+
+    # Some precision is lost as convert to int16 and back again
+    assert result.shape == signal.shape
+
+
+def test_read_write_sample_mismatch_error(tmp_path):
+    """Should raise an error if the sample rate is not the same"""
+    tmp_filename = tmp_path / "test.wav"
+
+    signal = np.ones((2, 10)) * 0.5
+    sample_rate = 16000.0
+
+    write_signal(
+        tmp_filename,
+        signal,
+        sample_rate=int(sample_rate),  # <-- Sample rate needs to be cast to int
+        floating_point=False,
+        strict=True,
+    )
+
+    with pytest.raises(ValueError):
+        read_signal(tmp_filename, sample_rate=8000, allow_resample=False)
+
+
+def test_read_write_channel_mismatch(tmp_path):
+    """Should raise an error if the sample rate is not the same"""
+    tmp_filename = tmp_path / "test.wav"
+
+    signal = np.ones((10, 2)) * 0.5
+    sample_rate = 16000.0
+
+    write_signal(
+        tmp_filename,
+        signal,
+        sample_rate=int(sample_rate),  # <-- Sample rate needs to be cast to int
+        floating_point=False,
+        strict=True,
+    )
+
+    # Correct number of channels - will read OK
+    read_signal(tmp_filename, sample_rate=16000, n_channels=2)
+
+    # Incorrect number of channels - will raise an error
+    with pytest.raises(ValueError):
+        read_signal(tmp_filename, sample_rate=16000, n_channels=1)
+
+
+def test_read_write_sample_with_resample(tmp_path):
+    """Should if the sample rate is not the same and allow_resample is True"""
+    tmp_filename = tmp_path / "test.wav"
+    signal = np.ones((10, 2)) * 0.5
+    sample_rate = 16000.0
+
+    write_signal(
+        tmp_filename,
+        signal,
+        sample_rate=int(sample_rate),  # <-- Sample rate needs to be cast to int
+        floating_point=False,
+        strict=True,
+    )
+
+    x = read_signal(tmp_filename, sample_rate=8000, allow_resample=True)
+
+    assert x.shape == (5, 2)
+
+
+def test_write_clipping(tmp_path):
+    """Should raise an error if the sample rate is not the same"""
+    tmp_filename = tmp_path / "test.wav"
+
+    signal = np.array([-1.0, 0.0, 1.0])  # <-- Clipping because 1.0 not OK
+
+    # strict=True, i.e. Clipping not allowed - will throw an error
+
+    with pytest.raises(ValueError):
+        write_signal(
+            tmp_filename,
+            signal,
+            sample_rate=16000,
+            floating_point=False,
+            strict=True,
+        )
+
+    # strict=False, i.e. Clipping allowed - will write OK but log warning
+    write_signal(
+        tmp_filename,
+        signal,
+        sample_rate=16000,
+        floating_point=False,
+        strict=False,
+    )
+
+    signal_read = read_signal(tmp_filename, sample_rate=16000)
+
+    # Note that the +1.0 is clipped to 0.99996948
+    assert signal_read == pytest.approx(np.array([-1.0, 0, 0.99996948]))
+    # This is the standard behaviour of soundfile and arises due to the
+    # asymmetric nature of the int16 format, i.e. -32768 to 32767
+
+
+def test_read_write_with_offset(tmp_path):
+    """Should if the sample rate is not the same and allow_resample is True"""
+    tmp_filename = tmp_path / "test.wav"
+    signal = np.ones((10, 2)) * 0.5
+    sample_rate = 10000
+
+    write_signal(
+        tmp_filename,
+        signal,
+        sample_rate=sample_rate,  # <-- Sample rate needs to be cast to int
+        floating_point=False,
+        strict=True,
+    )
+
+    x1 = read_signal(tmp_filename, offset=3, offset_is_samples=True)
+    assert x1.shape == (7, 2)
+
+    x2 = read_signal(tmp_filename, offset=0.0003, offset_is_samples=False)
+    assert x2.shape == (7, 2)
diff --git a/tests/utils/test_signal_processing.py b/tests/utils/test_signal_processing.py
index e61d448ae..62a91946b 100644
--- a/tests/utils/test_signal_processing.py
+++ b/tests/utils/test_signal_processing.py
@@ -6,6 +6,7 @@
     compute_rms,
     denormalize_signals,
     normalize_signal,
+    resample,
 )
 
 
@@ -135,3 +136,73 @@ def test_compute_rms():
     assert rms == pytest.approx(
         57.803515840, rel=pytest.rel_tolerance, abs=pytest.abs_tolerance
     )
+
+
+@pytest.mark.parametrize(
+    "input_sample_rate, input_shape, output_sample_rate, output_shape",
+    [
+        (16000, (100, 2), 16000, (100, 2)),
+        (16000, (100, 2), 8000, (50, 2)),
+        (16000, (100, 2), 32000, (200, 2)),
+        (16000, (100,), 8000, (50,)),
+        (16000, (100, 1), 8000, (50, 1)),
+        (16000, 100, 8000, (50,)),
+    ],
+)
+def test_resample(input_sample_rate, input_shape, output_sample_rate, output_shape):
+    """Test the signal resampling function"""
+    input_signal = np.ones(input_shape)
+
+    for method in ["soxr", "polyphase", "fft"]:
+        output_signal = resample(
+            signal=input_signal,
+            sample_rate=input_sample_rate,
+            new_sample_rate=output_sample_rate,
+            method=method,
+        )
+
+        assert output_signal.shape == output_shape
+
+
+def test_resample_default_to_soxr():
+    """Test the signal resampling function"""
+    input_signal = np.ones((100, 2))
+    output_signal_default = resample(
+        signal=input_signal, sample_rate=16000, new_sample_rate=8000
+    )
+    output_signal_soxr = resample(
+        signal=input_signal, sample_rate=16000, new_sample_rate=8000, method="soxr"
+    )
+
+    assert output_signal_default == pytest.approx(output_signal_soxr)
+
+
+def test_resample_raise_error_for_unknown_method():
+    """Test the signal resampling function"""
+    input_signal = np.ones((100, 2))
+    with pytest.raises(ValueError):
+        resample(
+            signal=input_signal,
+            sample_rate=16000,
+            new_sample_rate=8000,
+            method="unknown",
+        )
+
+
+def test_resample_with_3d_array():
+    """Test the signal resampling function"""
+    input_signal = np.ones((100, 2, 3))
+    for method in ["polyphase", "fft"]:
+        output_signal = resample(
+            signal=input_signal, sample_rate=16000, new_sample_rate=8000, method=method
+        )
+        assert output_signal.shape == (50, 2, 3)
+
+
+def test_resample_with_3d_array_error():
+    """Resample with 3d array should raise an error for soxr and polyphase methods"""
+    input_signal = np.ones((100, 2, 3))
+    with pytest.raises(ValueError):
+        resample(
+            signal=input_signal, sample_rate=16000, new_sample_rate=8000, method="soxr"
+        )